diff --git a/CHANGELOG.md b/CHANGELOG.md index 152b873..7ea8b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog +## 0.1.7 — registry credential + a pull error you can read (2026-09-08) + +- **No hand `docker login` on a new box** (monky-design-docs !225, doc 24 §4a). A manifest entry marked + `use: registry-auth` is the Harbor pull credential, not an env var: it is written to + `/docker/config.json` (0600, in a directory the agent owns) and the docker CLI is pointed at it + with an explicit **`DOCKER_CONFIG`**. That last part is the trap — the unit runs as `monky-deployd`, so a + `docker login` by a human or by root is invisible to the agent and looks exactly like no credential at all + (env-dev-01, 2026-09-08). Both seeded shapes are accepted: a JSON object, or `username:password` with the + registry from the new `registry_host` config. +- **`compose pull` failures carry the registry's own message** instead of `rc=1`. "no basic auth + credentials", "manifest unknown" and DNS failures now reach the journal and the report to tenancy. + ## 0.1.6 — 2026-09-07 - **A valid lease token is reused across applies.** Every apply requested a new lease, so a failing deploy diff --git a/config.example.yaml b/config.example.yaml index e2c714d..cfcddc6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,3 +44,7 @@ laptop_mode: false # true: offline exits 0 quietly; run withou # compose_project: monky-env-qa-02 # docker compose project name # docker_bin: docker # log_level: INFO + +# The registry the bundle pulls from. Used only when the seeded pull credential is a bare +# `username:password` (a JSON credential names its own registry). doc 24 §4a. +registry_host: harbor.tikali.net diff --git a/monky_deployd/__init__.py b/monky_deployd/__init__.py index 454ff37..7a6c9ee 100644 --- a/monky_deployd/__init__.py +++ b/monky_deployd/__init__.py @@ -4,4 +4,4 @@ Dials monky-tenancy over the mesh with the box's host identity, fetches the rend leases a deploy grant, logs in to OpenBao, reads its own secrets, runs `docker compose`, reports. Stdlib only; the optional `openziti` SDK is the `sdk` transport.""" -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/monky_deployd/agent.py b/monky_deployd/agent.py index 59f1817..ea9b799 100644 --- a/monky_deployd/agent.py +++ b/monky_deployd/agent.py @@ -24,6 +24,7 @@ from pathlib import Path from monky_deployd import __version__ from monky_deployd import bundle as bundlemod +from monky_deployd import registry as registrymod from monky_deployd import state as statemod from monky_deployd.bao import BaoClient, BaoError, BaoToken, ManifestPathError, kv_data_path from monky_deployd.bundle import Bundle, BundleError @@ -86,7 +87,7 @@ class Agent: def __init__(self, cfg: Config, *, prune: bool = False, docker: Docker | None = None): self.cfg = cfg self.prune = prune - self.docker = docker or Docker(cfg.docker_bin) + self.docker = docker or Docker(cfg.docker_bin, docker_config=str(cfg.docker_config_dir)) self.transport = build(cfg) self.tenancy_http = HttpClient( self.transport, cfg.tenancy.scheme, cfg.tenancy.host, cfg.tenancy.port, timeout=cfg.tenancy.timeout_s @@ -274,13 +275,29 @@ class Agent: if hdr_sha and hdr_sha != b.sha: log.warning("bundle header sha %s disagrees with content %s", _short(hdr_sha), _short(b.sha)) self._refusal_checks(b) - # secrets: lease -> login -> reads (values never logged; names only) + # secrets: lease -> login -> reads (values never logged; names only). An entry marked + # `use: registry-auth` is NOT an env var — it is the pull credential, written to the + # agent's own Docker config instead of `.env` (doc 24 §4a). entries = b.manifest.get("entries", []) + registry_entries = [e for e in entries if e.get("use") == registrymod.USE] + env_entries = [e for e in entries if e.get("use") != registrymod.USE] if entries: token = self._token_for_apply() - for e in entries: + for e in env_entries: self._values[e["var"]] = self.bao.kv_read(token, e["path"], cfg.env_id, e.get("version")) - log.info("read %d secret(s): %s", len(entries), ", ".join(sorted(self._values))) + for e in registry_entries: + raw = self.bao.kv_read(token, e["path"], cfg.env_id, e.get("version")) + try: + auth = registrymod.parse(raw, default_registry=cfg.registry_host) + registrymod.write_docker_config(cfg.docker_config_dir, auth) + except registrymod.RegistryAuthError as exc: + log.warning("registry credential unusable (%s) — private images will not pull", exc) + log.info( + "read %d secret(s): %s%s", + len(entries), + ", ".join(sorted(self._values)), + " (+ registry credential)" if registry_entries else "", + ) env_text = bundlemod.render_env(b.env_template, self._values) leftover = bundlemod.referenced_vars(env_text) if leftover: @@ -288,7 +305,17 @@ class Agent: release = self._promote(self._stage(b, env_text)) compose = self._compose() log.info("compose pull") - compose.pull() + try: + compose.pull() + except ComposeError as exc: + # the registry's own message is the diagnosis ("no basic auth credentials", "manifest + # unknown", a DNS failure). Swallowing it behind `rc=1` cost an SSH hunt on env-dev-01. + detail = " | ".join(line.strip() for line in (exc.output or "").splitlines() if line.strip())[-600:] + self.state.last_result = "failed" + self.state.last_error = f"compose pull failed: {detail or exc}" + log.error("compose pull failed (rc=%s): %s", exc.rc, detail or "(no output)") + self._report("failed", b.sha, detail=f"compose pull: {detail or exc}"[:900]) + return EX_FAIL log.info("compose up -d --remove-orphans") compose.up() ok, containers = compose.wait_healthy(cfg.healthy_timeout_s) diff --git a/monky_deployd/compose.py b/monky_deployd/compose.py index 79dfc84..ac94fbd 100644 --- a/monky_deployd/compose.py +++ b/monky_deployd/compose.py @@ -41,9 +41,13 @@ class Container: class Docker: - def __init__(self, docker_bin: str = "docker", timeout_s: int = 600): + def __init__(self, docker_bin: str = "docker", timeout_s: int = 600, docker_config: str | None = None): self.bin = docker_bin self.timeout_s = timeout_s + # doc 24 §4a: registry credentials live in a directory the AGENT owns, named explicitly + # rather than inherited from $HOME. The unit runs as `monky-deployd`, so a `docker login` + # by a human or by root is invisible here — which is exactly what cost env-dev-01 an hour. + self.docker_config = docker_config def available(self) -> bool: return shutil.which(self.bin) is not None @@ -59,7 +63,7 @@ class Docker: capture_output=True, text=True, timeout=timeout or self.timeout_s, - env={**os.environ, "COMPOSE_INTERACTIVE_NO_CLI": "1"}, + env=self._env(), ) except FileNotFoundError as exc: raise ComposeError(what, 127, f"{self.bin} not found") from exc @@ -70,6 +74,12 @@ class Docker: raise ComposeError(what, p.returncode, out.strip()) return out + def _env(self) -> dict: + env = {**os.environ, "COMPOSE_INTERACTIVE_NO_CLI": "1"} + if self.docker_config: + env["DOCKER_CONFIG"] = self.docker_config + return env + # -- facts ------------------------------------------------------------------------------------ def version(self) -> str | None: try: diff --git a/monky_deployd/config.py b/monky_deployd/config.py index 2d5db77..15ccb90 100644 --- a/monky_deployd/config.py +++ b/monky_deployd/config.py @@ -175,6 +175,9 @@ class Config: healthy_timeout_s: int = 300 compose_project: str = "" docker_bin: str = "docker" + # doc 24 §4a: the registry the bundle pulls from, used when the seeded credential is a bare + # `username:password` (a JSON credential names its own registry). + registry_host: str = "harbor.tikali.net" log_level: str = "INFO" path: str = DEFAULT_CONFIG_PATH @@ -191,6 +194,13 @@ class Config: def lock_path(self) -> Path: return Path(self.state_dir) / "lock" + @property + def docker_config_dir(self) -> Path: + """Where the agent keeps its OWN registry credentials (`DOCKER_CONFIG`). Not `$HOME`: the + unit runs as `monky-deployd`, and a human's or root's `docker login` must not be what the + agent depends on (doc 24 §4a).""" + return Path(self.state_dir) / "docker" + @property def is_prod(self) -> bool: if self.prod is not None: diff --git a/monky_deployd/registry.py b/monky_deployd/registry.py new file mode 100644 index 0000000..73882f1 --- /dev/null +++ b/monky_deployd/registry.py @@ -0,0 +1,80 @@ +"""The registry credential (monky-design-docs doc 24 §4a). + +The bundle's manifest carries one entry marked `use: registry-auth` — the estate-wide read-only +Harbor robot, copied by monky-tenancy into this environment's own prefix so the agent can read it +with the OpenBao policy it already has. It is NOT an env var (an entry in `.env` would put the +registry password into every container's environment), so it never reaches the compose file: it is +written to a Docker config **the agent owns**, and `DOCKER_CONFIG` points the docker CLI at it. + +That last part is the whole point. The unit runs as `monky-deployd`, whose home is the state dir, +so a `docker login` performed by a human or by root is invisible to it — the failure looks exactly +like "no credentials at all" (env-dev-01, 2026-09-08). + +Accepted shapes for the secret's value, because the seeded robot has been written both ways: + * a JSON object: `{"registry": …, "username": …, "password": …}` + * a `username:password` string, with the registry taken from `registry_host` config +""" + +from __future__ import annotations + +import base64 +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +log = logging.getLogger("monky-deployd.registry") + +USE = "registry-auth" + + +class RegistryAuthError(Exception): + pass + + +@dataclass +class RegistryAuth: + registry: str + username: str + password: str + + def docker_config(self) -> dict: + token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode() + return {"auths": {self.registry: {"auth": token}}} + + +def parse(value: str, *, default_registry: str) -> RegistryAuth: + """`value` is whatever the KV entry held; never logged, never echoed.""" + text = (value or "").strip() + if not text: + raise RegistryAuthError("empty registry credential") + if text.startswith("{"): + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise RegistryAuthError("registry credential is not valid JSON") from exc + user, pw = data.get("username"), data.get("password") + registry = data.get("registry") or default_registry + if not user or not pw: + raise RegistryAuthError("registry credential JSON needs username + password") + return RegistryAuth(registry=str(registry), username=str(user), password=str(pw)) + if ":" not in text: + raise RegistryAuthError("registry credential is neither JSON nor username:password") + user, _, pw = text.partition(":") + if not default_registry: + raise RegistryAuthError("username:password credential needs a configured registry host") + return RegistryAuth(registry=default_registry, username=user, password=pw) + + +def write_docker_config(dir_path: Path, auth: RegistryAuth) -> Path: + """0600 `config.json` in a directory the agent owns; DOCKER_CONFIG points the CLI at it.""" + dir_path = Path(dir_path) + dir_path.mkdir(parents=True, exist_ok=True) + dir_path.chmod(0o700) + target = dir_path / "config.json" + tmp = dir_path / "config.json.tmp" + tmp.write_text(json.dumps(auth.docker_config(), indent=2) + "\n") + tmp.chmod(0o600) + tmp.replace(target) + log.info("registry credential in place for %s (%s)", auth.registry, auth.username) + return target diff --git a/pyproject.toml b/pyproject.toml index 9886552..0f75288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "monky-deployd" -version = "0.1.6" +version = "0.1.7" description = "Monky backend pull agent: checkin -> bundle -> lease -> OpenBao -> docker compose -> report, over the ziti mesh (MONKY-ADR-0028)" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_registry_auth.py b/tests/test_registry_auth.py new file mode 100644 index 0000000..d7aa3be --- /dev/null +++ b/tests/test_registry_auth.py @@ -0,0 +1,53 @@ +"""The registry credential (doc 24 §4a): parsed from either seeded shape, written to a Docker +config the AGENT owns, never an env var, never logged.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from monky_deployd import registry as registrymod + + +def test_parses_json_and_user_colon_password(): + a = registrymod.parse( + json.dumps({"registry": "harbor.tikali.net", "username": "robot$pull", "password": "p4ss"}), + default_registry="ignored.example", + ) + assert (a.registry, a.username, a.password) == ("harbor.tikali.net", "robot$pull", "p4ss") + b = registrymod.parse("robot$pull:p4ss", default_registry="harbor.tikali.net") + assert (b.registry, b.username, b.password) == ("harbor.tikali.net", "robot$pull", "p4ss") + # a password containing a colon survives (partition on the FIRST one) + c = registrymod.parse("robot$pull:p4:ss", default_registry="h") + assert c.password == "p4:ss" + + +@pytest.mark.parametrize("bad", ["", " ", "no-colon-here", '{"username": "u"}', "{not json"]) +def test_refuses_what_it_cannot_use(bad): + with pytest.raises(registrymod.RegistryAuthError): + registrymod.parse(bad, default_registry="harbor.tikali.net") + + +def test_docker_config_is_written_0600_in_a_directory_the_agent_owns(tmp_path): + auth = registrymod.parse("robot$pull:p4ss", default_registry="harbor.tikali.net") + target = registrymod.write_docker_config(tmp_path / "docker", auth) + assert target.exists() + assert oct(target.stat().st_mode)[-3:] == "600" + assert oct(target.parent.stat().st_mode)[-3:] == "700" + cfg = json.loads(target.read_text()) + token = cfg["auths"]["harbor.tikali.net"]["auth"] + assert base64.b64decode(token).decode() == "robot$pull:p4ss" + # rewriting is idempotent (the agent does it every tick) + registrymod.write_docker_config(tmp_path / "docker", auth) + assert json.loads(target.read_text()) == cfg + + +def test_docker_runner_points_the_cli_at_that_directory(): + from monky_deployd.compose import Docker + + d = Docker("docker", docker_config="/var/lib/monky-deployd/docker") + assert d._env()["DOCKER_CONFIG"] == "/var/lib/monky-deployd/docker" + # unset when no directory is configured — never silently fall back to a human's $HOME + assert "DOCKER_CONFIG" not in Docker("docker")._env()