From 1392a082dacab712c0f2d1b86e059c57c554e2a6 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 4 Oct 2026 19:14:36 -0400 Subject: [PATCH] Fix Docker client installation on Debian 13 --- agent/install.sh | 17 ++++-- agent/tests/test_installer.py | 104 ++++++++++++++++++++++++++++++++++ docs/native-hosts.md | 7 +++ static/agent/install.sh | 17 ++++-- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/agent/install.sh b/agent/install.sh index 392b81b..5e15831 100644 --- a/agent/install.sh +++ b/agent/install.sh @@ -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(updater, 'ec-agent-update.py', 'exec') PY +# INSTALLATION_DOCKER: Debian 13 splits the client out of docker.io. 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 exit 1 + else + 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 fi - apt-get install -y --no-install-recommends docker.io - systemctl enable --now docker 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 # 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. diff --git a/agent/tests/test_installer.py b/agent/tests/test_installer.py index bb31ebc..c4937ca 100644 --- a/agent/tests/test_installer.py +++ b/agent/tests/test_installer.py @@ -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) RELEASE = next(block for block in BLOCKS if "# INSTALLATION_RELEASE:" in block) 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") PACKAGER = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(PACKAGER) @@ -230,6 +231,109 @@ class InstallerTests(unittest.TestCase): 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): def setUp(self): self.temporary = tempfile.TemporaryDirectory() diff --git a/docs/native-hosts.md b/docs/native-hosts.md index 43a43ea..a2fe461 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -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 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 polls. It first pulls `PLUTAINER_IMAGE` (default `ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for diff --git a/static/agent/install.sh b/static/agent/install.sh index 392b81b..5e15831 100644 --- a/static/agent/install.sh +++ b/static/agent/install.sh @@ -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(updater, 'ec-agent-update.py', 'exec') PY +# INSTALLATION_DOCKER: Debian 13 splits the client out of docker.io. 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 exit 1 + else + 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 fi - apt-get install -y --no-install-recommends docker.io - systemctl enable --now docker 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 # 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.