mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 04:36:15 +00:00
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:
@@ -1,6 +1,18 @@
|
||||
<!-- xlate:verbatim-fences -->
|
||||
# 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
|
||||
`<state_dir>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+31
-4
@@ -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")
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user