Split host setup into explicit entity actions
This commit is contained in:
51 files changed
+1023
-275
No files matched your search
@@ -91,12 +91,13 @@ def preflight(*, strict=True):
|
||||
|
||||
|
||||
def package_versions():
|
||||
architecture = run(["dpkg", "--print-architecture"]).stdout.strip()
|
||||
result = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\t${Version}\n"])
|
||||
installed = {}
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split("\t")
|
||||
if len(fields) == 3 and fields[1].split()[1:] == ["ok", "installed"]:
|
||||
installed[fields[0].removesuffix(":amd64")] = fields[2]
|
||||
installed[fields[0].removesuffix(":" + architecture)] = fields[2]
|
||||
return installed
|
||||
|
||||
|
||||
@@ -311,6 +312,88 @@ def docker_preflight(installed):
|
||||
return {**config, **DOCKER_CONFIG}
|
||||
|
||||
|
||||
DOCKER_KEY_SHA256 = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570"
|
||||
LOCAL_DOCKER = ["docker", "--host", "unix:///var/run/docker.sock"]
|
||||
|
||||
|
||||
def _docker_source(arch):
|
||||
key = Path("/etc/apt/keyrings/apex-docker.asc")
|
||||
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
|
||||
content = ("Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: trixie\n"
|
||||
f"Components: stable\nArchitectures: {arch}\nSigned-By: {key}\n")
|
||||
for path in (key, source):
|
||||
if any(part.is_symlink() for part in (path, *path.parents)):
|
||||
raise BaselineError(f"Refusing redirected APT path: {path}")
|
||||
if source.exists() and source.read_text() != content:
|
||||
raise BaselineError(f"Reconcile existing Docker repository configuration: {source}")
|
||||
if key.exists():
|
||||
data = key.read_bytes()
|
||||
else:
|
||||
url = "https://download.docker.com/linux/debian/gpg"
|
||||
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
|
||||
data = response.read(1024 * 1024)
|
||||
if hashlib.sha256(data).hexdigest() != DOCKER_KEY_SHA256:
|
||||
raise BaselineError("Docker repository signing key differs from the pinned release key.")
|
||||
write_managed(key, data)
|
||||
write_managed(source, content)
|
||||
|
||||
|
||||
def _docker_capabilities(root, *, complete_plugins=False):
|
||||
capabilities = {"docker_data_root": root}
|
||||
plugins = {"docker-compose-plugin": ("compose", ["compose", "version", "--short"]),
|
||||
"docker-buildx-plugin": ("buildx", ["buildx", "version"])}
|
||||
missing = []
|
||||
for package, (name, args) in plugins.items():
|
||||
result = run([*LOCAL_DOCKER, *args], check=False)
|
||||
if result.returncode or not result.stdout.strip():
|
||||
missing.append(package)
|
||||
else:
|
||||
capabilities[name] = result.stdout.strip()
|
||||
if missing:
|
||||
if complete_plugins and "docker-ce" in package_versions():
|
||||
with service_start_guard():
|
||||
install_missing(missing)
|
||||
return _docker_capabilities(root)
|
||||
raise BaselineError("Existing Docker needs functional plugins: " + ", ".join(missing))
|
||||
if not root:
|
||||
raise BaselineError("Unable to verify the local Docker data-root.")
|
||||
return capabilities
|
||||
|
||||
|
||||
@expected_errors
|
||||
def apply_docker_policy():
|
||||
if Path("/usr/bin/pveversion").exists():
|
||||
raise BaselineError("Docker installation on a Proxmox host requires separate administration.")
|
||||
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines()
|
||||
if "=" in line and not line.startswith("#"))
|
||||
arch = run(["dpkg", "--print-architecture"]).stdout.strip()
|
||||
if (release.get("ID", "").strip('"') != "debian" or
|
||||
release.get("VERSION_ID", "").strip('"') != "13" or arch not in ("amd64", "arm64")):
|
||||
raise BaselineError("Docker installation supports stock Debian 13 amd64 or arm64.")
|
||||
info = [*LOCAL_DOCKER, "info", "--format", "{{.DockerRootDir}}"]
|
||||
if shutil.which("docker"):
|
||||
current = run(info, check=False)
|
||||
if current.returncode == 0:
|
||||
return _docker_capabilities(current.stdout.strip(), complete_plugins=True)
|
||||
installed = package_versions()
|
||||
source = Path("/etc/apt/sources.list.d/apex-docker.sources")
|
||||
if (not source.exists() and
|
||||
(shutil.which("docker") or set(DOCKER_PACKAGES).intersection(installed))):
|
||||
raise BaselineError("Existing Docker installation is inactive; reconcile it before retrying.")
|
||||
config = docker_preflight(installed)
|
||||
with service_start_guard():
|
||||
install_missing(("ca-certificates",))
|
||||
_docker_source(arch)
|
||||
write_managed("/etc/docker/daemon.json", json.dumps(config, indent=2) + "\n")
|
||||
install_missing(DOCKER_PACKAGES)
|
||||
ensure_service("containerd")
|
||||
ensure_service("docker")
|
||||
root = run(info).stdout.strip()
|
||||
if root != DOCKER_CONFIG["data-root"]:
|
||||
raise BaselineError("Docker started with an unexpected data-root; inspect its configuration.")
|
||||
return _docker_capabilities(root)
|
||||
|
||||
|
||||
def configure_sources():
|
||||
write_managed("/etc/apt/sources.list.d/apex-zfs.sources",
|
||||
"Types: deb\nURIs: https://deb.debian.org/debian\nSuites: trixie\n"
|
||||
@@ -418,7 +501,7 @@ def repair_bouncer_credentials():
|
||||
|
||||
@expected_errors
|
||||
def apply_base_policy():
|
||||
"""The bounded configure/base policy: no Docker, storage or enrollment."""
|
||||
"""Apply the base package, logging, and firewall policy."""
|
||||
preflight(strict=False)
|
||||
with service_start_guard(), security_service_guard(BASE_PACKAGES):
|
||||
install_missing(BASE_PACKAGES)
|
||||
@@ -478,11 +561,19 @@ if __name__ == "__main__":
|
||||
import argparse
|
||||
from .state import ProvisionError, exclusive_lock
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-policy", action="store_true")
|
||||
scope = parser.add_mutually_exclusive_group()
|
||||
scope.add_argument("--base-policy", action="store_true")
|
||||
scope.add_argument("--docker-policy", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
preflight(strict=not args.base_policy)
|
||||
preflight(strict=not (args.base_policy or args.docker_policy))
|
||||
with exclusive_lock("/var/lib/apex-init/init.lock"):
|
||||
print(json.dumps(apply_base_policy() if args.base_policy else apply_baseline(), sort_keys=True))
|
||||
if args.docker_policy:
|
||||
result = apply_docker_policy()
|
||||
elif args.base_policy:
|
||||
result = apply_base_policy()
|
||||
else:
|
||||
result = apply_baseline()
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
except (BaselineError, OSError, ProvisionError) as exc:
|
||||
parser.exit(1, f"Baseline failed: {exc}\n")
|
||||
@@ -0,0 +1,120 @@
|
||||
from contextlib import nullcontext
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from provisioning import baseline
|
||||
|
||||
|
||||
class ManualDockerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name).resolve()
|
||||
(self.root / "etc").mkdir()
|
||||
(self.root / "etc/os-release").write_text("ID=debian\nVERSION_ID=13\n")
|
||||
self.commands = []
|
||||
self.arch = "arm64"
|
||||
self.running = False
|
||||
self.data_root = "/custom/data"
|
||||
self.missing_buildx = False
|
||||
self.versions = {}
|
||||
self.installations = []
|
||||
|
||||
def run_command(self, cmd, **kwargs):
|
||||
self.commands.append(cmd)
|
||||
if cmd == ["dpkg", "--print-architecture"]:
|
||||
return CompletedProcess(cmd, 0, self.arch + "\n", "")
|
||||
if cmd[-1] == "{{.DockerRootDir}}":
|
||||
return CompletedProcess(cmd, 0 if self.running else 1, self.data_root + "\n", "")
|
||||
if cmd[-2:] in (["version", "--short"], ["buildx", "version"]):
|
||||
if self.missing_buildx and cmd[-2:] == ["buildx", "version"]:
|
||||
if ["docker-buildx-plugin"] not in self.installations:
|
||||
return CompletedProcess(cmd, 1, "", "plugin missing")
|
||||
return CompletedProcess(cmd, 0, "installed\n", "")
|
||||
raise AssertionError(cmd)
|
||||
|
||||
def apply(self):
|
||||
def target(path):
|
||||
path = Path(path)
|
||||
return path if self.root in path.parents else self.root / str(path).lstrip("/")
|
||||
|
||||
with (patch.object(baseline, "Path", side_effect=target),
|
||||
patch.object(baseline, "run", side_effect=self.run_command),
|
||||
patch.object(baseline, "package_versions", return_value=self.versions),
|
||||
patch.object(baseline.shutil, "which", return_value="/usr/bin/docker" if self.running else None),
|
||||
patch.object(baseline, "install_missing", side_effect=self.installations.append)):
|
||||
return baseline.apply_docker_policy()
|
||||
|
||||
def test_working_engine_keeps_local_data_root_without_writes(self):
|
||||
self.running = True
|
||||
self.versions = {"docker.io": "1"}
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/custom/data")
|
||||
self.assertEqual(self.installations, [])
|
||||
self.assertFalse((self.root / "etc/docker").exists())
|
||||
|
||||
def test_pve_refused_before_packages_or_daemon(self):
|
||||
binary = self.root / "usr/bin/pveversion"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.touch()
|
||||
with self.assertRaisesRegex(baseline.BaselineError, "Proxmox"):
|
||||
self.apply()
|
||||
self.assertEqual(self.commands, [])
|
||||
|
||||
def test_unsupported_architecture_refused(self):
|
||||
self.arch = "i386"
|
||||
with self.assertRaisesRegex(baseline.BaselineError, "amd64|arm64"):
|
||||
self.apply()
|
||||
self.assertEqual(self.installations, [])
|
||||
|
||||
def test_existing_runtime_packages_not_replaced(self):
|
||||
self.versions = {"containerd": "1.7"}
|
||||
with self.assertRaisesRegex(baseline.BaselineError, "containerd"):
|
||||
self.apply()
|
||||
self.assertEqual(self.installations, [])
|
||||
|
||||
def test_fresh_arm64_install_verifies_the_pinned_key_and_starts_local_engine(self):
|
||||
key = b"test repository signing key"
|
||||
self.data_root = "/srv/docker"
|
||||
|
||||
def start(service):
|
||||
if service == "docker":
|
||||
self.running = True
|
||||
|
||||
with (patch.object(baseline, "service_start_guard", return_value=nullcontext()),
|
||||
patch.object(baseline.urllib.request, "urlopen", return_value=io.BytesIO(key)),
|
||||
patch.object(baseline, "DOCKER_KEY_SHA256", hashlib.sha256(key).hexdigest()),
|
||||
patch.object(baseline, "ensure_service", side_effect=start)):
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/srv/docker")
|
||||
source = (self.root / "etc/apt/sources.list.d/apex-docker.sources").read_text()
|
||||
self.assertIn("Architectures: arm64", source)
|
||||
self.assertIn("Signed-By: ", source)
|
||||
self.assertEqual((self.root / "etc/apt/keyrings/apex-docker.asc").read_bytes(), key)
|
||||
self.assertEqual(json.loads((self.root / "etc/docker/daemon.json").read_text())["data-root"],
|
||||
"/srv/docker")
|
||||
self.assertIn("docker-compose-plugin", self.installations[-1])
|
||||
self.assertIn("docker-buildx-plugin", self.installations[-1])
|
||||
self.assertNotIn("ufw", self.installations[-1])
|
||||
|
||||
def test_untrusted_signing_key_cannot_publish_apt_source_or_install_docker(self):
|
||||
with (patch.object(baseline, "service_start_guard", return_value=nullcontext()),
|
||||
patch.object(baseline.urllib.request, "urlopen", return_value=io.BytesIO(b"wrong key"))):
|
||||
with self.assertRaisesRegex(baseline.BaselineError, "signing key"):
|
||||
self.apply()
|
||||
self.assertFalse((self.root / "etc/apt/sources.list.d/apex-docker.sources").exists())
|
||||
self.assertFalse((self.root / "etc/docker/daemon.json").exists())
|
||||
self.assertEqual(self.installations, [("ca-certificates",)])
|
||||
|
||||
def test_working_ce_engine_only_installs_the_missing_plugin(self):
|
||||
self.running = True
|
||||
self.missing_buildx = True
|
||||
self.versions = {"docker-ce": "1"}
|
||||
with patch.object(baseline, "service_start_guard", return_value=nullcontext()):
|
||||
self.assertEqual(self.apply()["docker_data_root"], "/custom/data")
|
||||
self.assertEqual(self.installations, [["docker-buildx-plugin"]])
|
||||
self.assertFalse((self.root / "etc/docker").exists())
|
||||
Reference in new issue
Block a user