feat: pull private images without a hand docker login; surface the pull error

Design merged first: monky-design-docs !225 (doc 24 §4a). Pairs with monky-tenancy!40, which
copies the estate-wide read-only Harbor robot into each environment's own prefix and marks the
manifest entry `use: registry-auth`.

- That entry is not an env var (it would otherwise land in .env and therefore in every container's
  environment). The agent parses it — JSON, or `username:password` with the new `registry_host` —
  and writes `<state_dir>/docker/config.json` 0600 in a directory it owns, with an explicit
  DOCKER_CONFIG pointing the docker CLI at it. The unit runs as monky-deployd, so a `docker login`
  by a human or by root is invisible to the agent: that is what made env-dev-01 look like it had no
  credential at all after the operator had just logged in.
- `compose pull` failures now carry the registry's own message ("no basic auth credentials",
  "manifest unknown", DNS) into the journal and the report instead of `rc=1`.
- Tests: both credential shapes, the refusals, 0600/0700 modes, idempotent rewrite, and that the
  runner never silently falls back to a human's $HOME.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLB7jieMNRkTsJ2epr4Ds1
This commit is contained in:
2026-09-08 15:45:22 +00:00
parent 35949a9246
commit b25c6b3b8b
9 changed files with 205 additions and 9 deletions
+12 -2
View File
@@ -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: