Fix Docker client installation on Debian 13

This commit is contained in:
2026-10-04 19:14:36 -04:00
parent fae7775c72
commit 1392a082da
4 changed files with 137 additions and 8 deletions
+13 -4
View File
@@ -191,15 +191,24 @@ if len(versions) != 1 or versions[0] != manifest.get('version') or not re.fullma
compile(agent, 'ec-agent.py', 'exec') compile(agent, 'ec-agent.py', 'exec')
compile(updater, 'ec-agent-update.py', 'exec') compile(updater, 'ec-agent-update.py', 'exec')
PY PY
# INSTALLATION_DOCKER: Debian 13 splits the client out of docker.io.
if ! command -v docker >/dev/null; then if ! command -v docker >/dev/null; then
if [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then if [ "$VERSION_ID" = 13 ] && [ "$(dpkg-query -W -f='${Status}' docker.io 2>/dev/null)" = 'install ok installed' ]; then
# Recover an earlier daemon-only install, even if it created Docker data.
# Add only the client; do not replace or restart the existing engine.
apt-get install -y --no-install-recommends --no-remove docker-cli
elif [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then
echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2 echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2
exit 1 exit 1
fi else
apt-get install -y --no-install-recommends docker.io docker_packages=(docker.io)
if [ "$VERSION_ID" = 13 ]; then docker_packages+=(docker-cli); fi
apt-get install -y --no-install-recommends "${docker_packages[@]}"
systemctl enable --now docker systemctl enable --now docker
fi
fi fi
docker info >/dev/null 2>&1 || { echo 'Docker is installed but unavailable. Start or repair it, then rerun.' >&2; exit 1; } command -v docker >/dev/null || { echo 'Docker CLI is missing after installation. Repair the Docker client package, then rerun.' >&2; exit 1; }
docker info >/dev/null || { echo 'Docker is installed but unavailable. Inspect systemctl status docker and journalctl -u docker, then rerun.' >&2; exit 1; }
install -d -m 0700 /etc/electric-cherry install -d -m 0700 /etc/electric-cherry
# Wait for an already-running updater to finish; never interrupt its rollback. # Wait for an already-running updater to finish; never interrupt its rollback.
# The same lock also prevents a timer firing while files are being installed. # The same lock also prevents a timer firing while files are being installed.
+104
View File
@@ -20,6 +20,7 @@ PREFLIGHT = next(block for block in BLOCKS if "# INSTALLATION_PREFLIGHT:" in blo
OWNERSHIP = next(block for block in BLOCKS if "# INSTALLATION_OWNERSHIP:" in block) OWNERSHIP = next(block for block in BLOCKS if "# INSTALLATION_OWNERSHIP:" in block)
RELEASE = next(block for block in BLOCKS if "# INSTALLATION_RELEASE:" in block) RELEASE = next(block for block in BLOCKS if "# INSTALLATION_RELEASE:" in block)
CLEANUP = re.search(r"installation_cleanup\(\) \{.*?\n\}", SOURCE, re.DOTALL).group() CLEANUP = re.search(r"installation_cleanup\(\) \{.*?\n\}", SOURCE, re.DOTALL).group()
DOCKER = SOURCE[SOURCE.index("# INSTALLATION_DOCKER:"):SOURCE.index("install -d -m 0700 /etc/electric-cherry")]
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).resolve().parents[1] / "package_release.py") SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).resolve().parents[1] / "package_release.py")
PACKAGER = importlib.util.module_from_spec(SPEC) PACKAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGER) SPEC.loader.exec_module(PACKAGER)
@@ -230,6 +231,109 @@ class InstallerTests(unittest.TestCase):
self.preflight() self.preflight()
class DockerInstallerTests(unittest.TestCase):
def run_docker_setup(self, version="13", cli=False, engine=False, available=True,
data=False, config=False, apt_status=0, supply_cli=True):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
docker_data = base / "docker-data"
docker_config = base / "daemon.json"
if data:
docker_data.mkdir()
(docker_data / "sentinel").write_text("existing containers")
if config:
docker_config.write_text("existing configuration")
block = DOCKER.replace("/var/lib/docker", str(docker_data)).replace("/etc/docker/daemon.json", str(docker_config))
# Simulate Debian's package split and Docker state. All commands that
# could install packages or touch services are shell stubs.
script = r'''
set -euo pipefail
command() { [ "$*" = '-v docker' ] && [ "$EC_TEST_CLI" = true ]; }
dpkg-query() {
[ "$*" = '-W -f=${Status} docker.io' ] || exit 90
[ "$EC_TEST_ENGINE" = true ] || return 1
printf 'install ok installed'
}
apt-get() {
printf 'apt-get %s\n' "$*"
[ "$EC_TEST_APT_STATUS" = 0 ] || return "$EC_TEST_APT_STATUS"
if [ "$EC_TEST_SUPPLY_CLI" = true ]; then
case " $* " in
*' docker-cli '*) EC_TEST_CLI=true ;;
*' docker.io '*) if [ "$VERSION_ID" = 12 ]; then EC_TEST_CLI=true; fi ;;
esac
fi
}
systemctl() { printf 'systemctl %s\n' "$*"; }
docker() {
[ "$EC_TEST_CLI" = true ] || return 127
[ "$*" = info ] || exit 91
if [ "$EC_TEST_AVAILABLE" != true ]; then
echo 'Cannot connect to the Docker daemon' >&2
return 1
fi
}
'''
environment = {**os.environ, "VERSION_ID": version,
"EC_TEST_CLI": str(cli).lower(), "EC_TEST_ENGINE": str(engine).lower(),
"EC_TEST_AVAILABLE": str(available).lower(), "EC_TEST_APT_STATUS": str(apt_status),
"EC_TEST_SUPPLY_CLI": str(supply_cli).lower()}
result = subprocess.run(["bash", "-c", script + block], env=environment, capture_output=True, text=True)
if data:
self.assertEqual((docker_data / "sentinel").read_text(), "existing containers")
if config:
self.assertEqual(docker_config.read_text(), "existing configuration")
return result
def test_fresh_debian_12_and_13_install_both_engine_and_client(self):
for version, packages in (("12", "docker.io"), ("13", "docker.io docker-cli")):
with self.subTest(version=version):
result = self.run_docker_setup(version=version)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [
f"apt-get install -y --no-install-recommends {packages}",
"systemctl enable --now docker"])
def test_rerun_repairs_debian_13_missing_client_without_touching_engine(self):
result = self.run_docker_setup(engine=True, data=True, config=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [
"apt-get install -y --no-install-recommends --no-remove docker-cli"])
def test_existing_docker_including_ce_is_reused_without_package_or_service_changes(self):
for engine in (False, True):
with self.subTest(debian_engine=engine):
result = self.run_docker_setup(cli=True, engine=engine, data=True, config=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "")
def test_unknown_existing_data_or_configuration_still_blocks_engine_installation(self):
for data, config in ((True, False), (False, True)):
with self.subTest(data=data, config=config):
result = self.run_docker_setup(data=data, config=config)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Existing Docker data/configuration", result.stderr)
self.assertEqual(result.stdout, "")
def test_unavailable_existing_engine_exposes_docker_error_without_restarting_it(self):
result = self.run_docker_setup(cli=True, available=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cannot connect to the Docker daemon", result.stderr)
self.assertIn("journalctl -u docker", result.stderr)
self.assertEqual(result.stdout, "")
def test_package_failure_stops_before_starting_docker(self):
result = self.run_docker_setup(apt_status=42)
self.assertEqual(result.returncode, 42)
self.assertNotIn("systemctl", result.stdout)
def test_missing_client_is_distinguished_from_an_unavailable_daemon(self):
result = self.run_docker_setup(supply_cli=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Docker CLI is missing", result.stderr)
self.assertNotIn("Docker is installed but unavailable", result.stderr)
class ReleaseTests(unittest.TestCase): class ReleaseTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
+7
View File
@@ -37,6 +37,13 @@ the agent owns local execution and its durable operation journal.
A fresh destination host downloads centrally stored game files without manual A fresh destination host downloads centrally stored game files without manual
copying or per-container setup. EC does not supply the game content itself. copying or per-container setup. EC does not supply the game content itself.
The installer uses Debian's Docker packages: `docker.io` on Debian 12, plus
`docker-cli` on Debian 13. Rerunning it after an earlier Debian 13 install that
omitted the client adds the missing client without restarting the existing engine.
An already available Docker installation (including Docker CE) is reused. If the
daemon is unavailable, the installer reports the Docker error and stops before
enrollment; inspect `systemctl status docker` and `journalctl -u docker`.
The controller reconciles automatic setup on agent heartbeats and operation The controller reconciles automatic setup on agent heartbeats and operation
polls. It first pulls `PLUTAINER_IMAGE` (default polls. It first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for `ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for
+13 -4
View File
@@ -191,15 +191,24 @@ if len(versions) != 1 or versions[0] != manifest.get('version') or not re.fullma
compile(agent, 'ec-agent.py', 'exec') compile(agent, 'ec-agent.py', 'exec')
compile(updater, 'ec-agent-update.py', 'exec') compile(updater, 'ec-agent-update.py', 'exec')
PY PY
# INSTALLATION_DOCKER: Debian 13 splits the client out of docker.io.
if ! command -v docker >/dev/null; then if ! command -v docker >/dev/null; then
if [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then if [ "$VERSION_ID" = 13 ] && [ "$(dpkg-query -W -f='${Status}' docker.io 2>/dev/null)" = 'install ok installed' ]; then
# Recover an earlier daemon-only install, even if it created Docker data.
# Add only the client; do not replace or restart the existing engine.
apt-get install -y --no-install-recommends --no-remove docker-cli
elif [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then
echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2 echo 'Existing Docker data/configuration detected without a Docker CLI. Repair Docker, then rerun.' >&2
exit 1 exit 1
fi else
apt-get install -y --no-install-recommends docker.io docker_packages=(docker.io)
if [ "$VERSION_ID" = 13 ]; then docker_packages+=(docker-cli); fi
apt-get install -y --no-install-recommends "${docker_packages[@]}"
systemctl enable --now docker systemctl enable --now docker
fi
fi fi
docker info >/dev/null 2>&1 || { echo 'Docker is installed but unavailable. Start or repair it, then rerun.' >&2; exit 1; } command -v docker >/dev/null || { echo 'Docker CLI is missing after installation. Repair the Docker client package, then rerun.' >&2; exit 1; }
docker info >/dev/null || { echo 'Docker is installed but unavailable. Inspect systemctl status docker and journalctl -u docker, then rerun.' >&2; exit 1; }
install -d -m 0700 /etc/electric-cherry install -d -m 0700 /etc/electric-cherry
# Wait for an already-running updater to finish; never interrupt its rollback. # Wait for an already-running updater to finish; never interrupt its rollback.
# The same lock also prevents a timer firing while files are being installed. # The same lock also prevents a timer firing while files are being installed.