10 Commits

Author SHA1 Message Date
monky-deployd-merger 7343970e2e Merge branch 'fix/version-0.1.10' into 'main'
chore: __version__ 0.1.10

See merge request tikali/applications/monky/monky-deployd!16
2026-09-13 00:26:21 +00:00
mdella 96c5dbbeb5 chore: __version__ 0.1.10 (the tag guard caught pyproject-only bump; tag pipeline 7977)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASnneBmT7rfaJLE8NGNw7S
2026-09-13 00:25:11 +00:00
monky-deployd-merger fead1f999b Merge branch 'fix/sites-fmt-roam-and-identity-acl' into 'main'
fix: sites fmt|cbs|pdx|roam + identity ACL watcher — 0.1.10 (deployd#3)

Closes #3

See merge request tikali/applications/monky/monky-deployd!15
2026-09-13 00:21:30 +00:00
mdella 037782e1ff fix: accept sites fmt|cbs|pdx|roam (tenancy 0.7.x) and keep the identity read grant alive across tunneller rewrites — 0.1.10
deployd#3 (DD-0620): every kit for a backend registered since 2026-09-08 died at `--site`.
env-dev-08 (2026-09-11..13): two days of "identity is not readable" ticks — ziti-edge-tunnel
re-creates the file with mode 0600, the ACL mask goes to ---, group membership stops helping.
identity-acl.sh + monky-deployd-identity-acl.path re-apply the grant on every directory change.

Doc-Drift: DD-0620 fixed
Closes #3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASnneBmT7rfaJLE8NGNw7S
2026-09-13 00:17:35 +00:00
mdella fe7b0c6922 Merge branch 'fix/upgrade-keeps-the-timer' into 'main'
fix(packaging): an upgrade must not stop and disable the agent — 0.1.9

See merge request tikali/applications/monky/monky-deployd!14
2026-09-08 21:10:53 -07:00
mdella 4ff2e29fbc fix(packaging): an upgrade must not stop and disable the agent — 0.1.9
dpkg calls the OLD package's prerm on an UPGRADE as well as on a removal (rpm passes a
remaining-instance count), and preremove.sh ran `systemctl disable --now monky-deployd.timer`
unconditionally. Upgrading env-dev-01 and env-dev-08 to 0.1.8 today stopped and disabled both
agents.

The failure is silent, which is the dangerous part: the box stays reachable, the containers keep
running, and nothing reports that check-ins have ceased — the backend just stops converging. A
fleet upgrade would have taken every agent offline at once and looked like a success.

preremove.sh now returns early for every upgrade shape (upgrade, failed-upgrade, deconfigure,
rpm's 1) and only disables on a real removal. postinstall.sh try-restarts the long-lived proxy
unit so it picks up the new code; the timer needs nothing, since each tick is a fresh process.

Tests drive the script with a fake systemctl on PATH and assert an upgrade touches no units.
OPERATIONS.md warns that a box coming FROM 0.1.8 or earlier still needs its timer re-enabled by
hand, because the old prerm has already run by then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLB7jieMNRkTsJ2epr4Ds1
2026-09-09 04:07:30 +00:00
mdella 17a64843f1 Merge branch 'fix/onboarding-identity-and-disk' into 'main'
fix(onboarding): identity read that survives a rewrite, disk refused before the pull — 0.1.8

See merge request tikali/applications/monky/monky-deployd!13
2026-09-08 18:58:55 -07:00
mdella 43615a6fda fix(onboarding): identity read that survives a rewrite, disk refused before the pull — 0.1.8
Three faults from one agent-managed onboarding (env-dev-08, 2026-09-09), each of which pointed
the diagnosis away from the actual fault.

1. install.sh granted the agent's read on the ziti identity with a POSIX ACL. ziti-edge-tunnel
   rewrites that file on a controller config update and the rewrite drops the ACL: the agent
   applied cleanly at 01:21 and was failing every tick by 01:32. Group membership survives the
   rewrite (the file stays ziti:ziti 0640), so install.sh and the package postinstall now add
   monky-deployd to the `ziti` group, and a default ACL on the identity directory carries the
   grant onto a newly created file. The explicit ACLs stay for the boxes that need them.

2. openziti.load() accepts an unreadable or malformed identity: the C SDK logs "configuration is
   invalid" and returns a context that only fails at dial, as a bare TypeError, which the
   transport reported as a missing intercept or a policy gap. The SDK transport now reads and
   parses the identity itself and names the real fault first.

3. The disk pre-flight ran only when the bundle declared disk_need_bytes, so a bundle without one
   died mid-pull with containerd's "no space left on device" — which reads as a registry fault.
   A bundle that declares no size now has to clear the headroom floor, and the pre-flight measures
   containerd's root as well as the docker data-root: docker 29 keeps image layers in the
   containerd image store, and on env-dev-08 those sat on different filesystems (93 GiB free where
   the agent looked, 2.8 GiB where the pull wrote).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLB7jieMNRkTsJ2epr4Ds1
2026-09-09 01:48:49 +00:00
mdella cc9dcebd7e Merge branch 'feat/registry-auth' into 'main'
feat: registry credential from the manifest (no hand docker login) + readable pull errors

See merge request tikali/applications/monky/monky-deployd!12
2026-09-08 09:54:48 -07:00
mdella b25c6b3b8b 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
2026-09-08 15:45:22 +00:00
25 changed files with 625 additions and 29 deletions
+61
View File
@@ -1,6 +1,67 @@
<!-- xlate:verbatim-fences -->
# Changelog
## 0.1.10 — sites `fmt|pdx|roam`, and a read grant that outlives the tunneller (2026-09-13)
- **Install kits for every backend registered since 2026-09-08 died at `--site`** (deployd#3,
DD-0620): tenancy 0.7.x issues `fmt | pdx | roam`, deployd still enforced `cbs | pdx`. `SITES`,
`install.sh` and `config.example.yaml` now accept `fmt | cbs | pdx | roam` (`cbs` = the deprecated
alias of `fmt` on rows registered before the rename) and a test pins the set to tenancy's.
- **The agent's read on the ziti identity now survives a tunneller rewrite for real.** 0.1.8 made
`monky-deployd` a member of the `ziti` group; that is not enough: `ziti-edge-tunnel` re-creates the
file with mode `0600`, which sets the ACL mask to `---` and defeats the group read and the named-user
entry alike (env-dev-08, 2026-09-11 → 09-13: two days of *"identity is not readable"* ticks under a
healthy tunnel; the backend went `offline`). New `identity-acl.sh` re-applies the entries **and the
mask**; `monky-deployd-identity-acl.path` re-runs it whenever the identity directory changes;
`install.sh` and the package postinstall arm it.
## 0.1.9 — an upgrade no longer stops the agent (2026-09-09)
- **`dpkg -i` over a running agent disabled it.** dpkg calls the OLD package's `prerm` on an
**upgrade** as well as on a removal (rpm passes a remaining-instance count), and `preremove.sh`
ran `systemctl disable --now monky-deployd.timer` unconditionally. Upgrading env-dev-01 and
env-dev-08 from 0.1.6/0.1.7 to 0.1.8 stopped and **disabled** both agents. It is silent: the box
stays up, the containers keep running, and nothing reports that check-ins have ceased — the
backend simply stops converging. `preremove.sh` now returns early for every upgrade shape
(`upgrade`, `failed-upgrade`, `deconfigure`, rpm's `1`), and `postinstall.sh` `try-restart`s the
long-lived proxy unit so it picks up the new code. A fleet upgrade would have taken every agent
offline at once.
## 0.1.8 — onboarding: keep the identity readable, refuse a full disk (2026-09-09)
Three faults from one onboarding (env-dev-08, agent-managed, 2026-09-09), each of which sent the
diagnosis somewhere other than the fault.
- **The agent's read on the ziti identity now survives a tunneller rewrite.** `install.sh` granted it
with a POSIX ACL. `ziti-edge-tunnel` rewrites the identity file whenever the controller sends a config
update, and the rewrite drops the ACL — the agent had applied cleanly and was failing every tick six
minutes later. The durable grant is **group membership**: `install.sh` and the package's postinstall
now add `monky-deployd` to the identity file's group (`ziti`) when it exists, and a **default ACL** on
the identity directory carries the grant onto a freshly created file. The explicit ACLs stay.
- **An unusable identity says so.** `openziti.load()` accepts an unreadable or malformed identity, logs
"configuration is invalid" at the C layer and hands back a context that fails LATER, at dial, as a
bare `TypeError` — which the transport reported as a missing intercept or a policy gap. The SDK
transport now reads and parses the identity itself first, and names the real fault (absent, not
readable by this uid, or not JSON) before anything touches the mesh.
- **A full disk is refused before the pull, and the right disk is measured.** The pre-flight ran only
when the bundle declared `disk_need_bytes`; a bundle without one sailed past it and died mid-pull with
containerd's `no space left on device`, which reads as a registry fault. A bundle that declares no size
now has to clear the headroom floor. The pre-flight also measures **containerd's root as well as the
docker data-root**: docker 29 keeps image layers in the containerd image store, and on env-dev-08 those
were on different filesystems — 93 GiB free where the agent looked, 2.8 GiB where the pull wrote.
## 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
+5 -1
View File
@@ -3,7 +3,7 @@
# simple lists, comments. Keys not listed here are a config error.
env_id: env-qa-02 # env-<tier>-<nn> (or a grandfathered legacy id); MUST match the token's env
site: cbs # cbs | pdx (lowercase DC code)
site: cbs # fmt | pdx | roam as tenancy issues it (cbs = deprecated alias of fmt, still on rows registered before 2026-09-08)
transport: sdk # sdk (OpenZiti Python SDK, default) | proxy (monky-deployd-proxy.service) | system (tunneler `run` mode / plain DNS)
identity: /opt/openziti/etc/identities/monky-host.env-qa-02.json # the box's host identity (read via ACL)
@@ -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
+14 -2
View File
@@ -9,6 +9,7 @@
| `monky-deployd.service` | `Type=oneshot`, `monky-deployd run --once` as user `monky-deployd` (+ `docker` group); `SuccessExitStatus=75`; hardened (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=/var/lib/monky-deployd /etc/monky-deployd /run/docker.sock`, `UMask=0077`, no capabilities) |
| `monky-deployd-proxy.service` | only with `transport: proxy`: `ziti tunnel proxy -i <identity> monky.tenancy.deploy:18443 openbao:18200` as user `ziti`; `EnvironmentFile=/etc/monky-deployd/proxy.env` |
| `ziti-edge-tunnel.service` | the host identity's tunneler in **`run-host`** mode (drop-in `run-host.conf` written by `install.sh`) |
| `monky-deployd-identity-acl.path` | re-runs `identity-acl.sh` whenever `/opt/openziti/etc/identities` changes, so the agent keeps its read after the tunneller rewrites the identity (0.1.10) |
```sh
systemctl status monky-deployd.timer monky-deployd.service
@@ -22,7 +23,17 @@ monky-deployd status --json | jq .
Files: `/etc/monky-deployd/config.yaml` (0640 root:monky-deployd), `/etc/monky-deployd/openbao-ca.pem`,
`/etc/monky-deployd/bootstrap.jwt` (only until the first login), `/var/lib/monky-deployd/{bao.token,state.json,lock}`,
`/var/lib/monky-deployd/<env>/releases/<sha>/` + `current` (the compose project dir, `.env` 0600),
`/opt/openziti/etc/identities/monky-host.<env>.json` (ziti:ziti 0600 + ACL `u:monky-deployd:r`).
`/opt/openziti/etc/identities/monky-host.<env>.json` (ziti:ziti 0640; the agent reads it as a member of
the `ziti` **group**, plus an explicit ACL `u:monky-deployd:r` and a default ACL on the directory).
> The group is the grant that matters. `ziti-edge-tunnel` rewrites the identity file whenever the
> controller sends a config update, and the rewrite drops POSIX ACLs — an ACL-only box works until the
> first refresh and then fails every tick (env-dev-08, 2026-09-09). On a host where the tunneller was
> installed independently of the kit, check `id monky-deployd` for `ziti` before anything else.
> **Upgrading the agent.** `dpkg -i` keeps the timer enabled from 0.1.9 on. On a box upgraded from
> 0.1.8 or earlier the old package's `prerm` already ran and **disabled** it — check
> `systemctl is-enabled monky-deployd.timer` after the upgrade and `systemctl enable --now` it if needed.
## Reading the journal
@@ -31,9 +42,10 @@ Files: `/etc/monky-deployd/config.yaml` (0640 root:monky-deployd), `/etc/monky-d
| `checkin: action=none desired=… applied=…` then `healthy; heartbeat reported` | converged |
| `checkin: action=apply …``read 3 secret(s): GEMINI_API_KEY, …``promoted release …``applied …` | a deploy |
| `refused: ENV_INCOMPLETE: unresolved: X` | the bundle needs a variable no manifest entry supplies — fix the descriptor / set the secret in the console; nothing was started |
| `refused: DISK_INSUFFICIENT: docker data-root has N MiB free, bundle needs M MiB` | free space (the env-dev-09 lesson): grow the data-root disk or prune |
| `refused: DISK_INSUFFICIENT: image storage (…) has N MiB free, …` | free space: grow the disk or prune. The paths in the message are every filesystem a pull can fill — docker's data-root AND containerd's root, which docker 29 fills with image layers and which is often a different disk (env-dev-08). A bundle that declares no size must still clear `disk.headroom_bytes` |
| `refused: PRIVILEGED_REFUSED` / `ROLLBACK_REFUSED` | the bundle needs `allow_privileged` / `allow_rollback` in its `agent` profile |
| `temporary network failure` (exit 75) | mesh/tenancy unreachable — check `ziti-edge-tunnel`, the identity's terminators, `monky.tenancy.deploy` health |
| `transport sdk: identity … is not readable by this user` | the agent lost its read on the identity file — `ziti-edge-tunnel` re-created it with mode `0600`, which sets the ACL mask to `---` (group membership does not help then). Run `/usr/share/monky-deployd/identity-acl.sh` and check `systemctl is-active monky-deployd-identity-acl.path` (0.1.10 re-applies it on every directory change) |
| `AGENT_UNAUTHENTICATED: bearer refused` (exit 1) | the grant was superseded (kit re-revealed / retire) or the token revoked → re-run the install kit |
| `AGENT_ENV_MISMATCH` (exit 78) | the token belongs to another env than `config.yaml` — fix the config or re-issue the identity; the timer keeps firing but every tick exits 78 immediately (no storm) |
| `failed: docker compose pull failed (rc=1)` | registry/pull problem; compose output is in the report's tail and in the journal |
+1 -1
View File
@@ -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.10"
+49 -14
View File
@@ -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)
@@ -326,16 +353,24 @@ class Agent:
raise Refusal("PRIVILEGED_REFUSED", ", ".join(findings) + " (bundle.json allow_privileged is not set)")
if self.state.is_rollback(b.sha) and not b.flag("allow_rollback"):
raise Refusal("ROLLBACK_REFUSED", f"{b.sha[:12]} was applied before; allow_rollback is not set")
# A bundle that declares its size gets the full sum; one that does not still has to clear
# the headroom floor. Without the floor a nearly-full box sails past this check and dies
# mid-pull with containerd's "no space left on device", which reads as a registry fault
# and costs an SSH hunt (env-dev-08, 2026-09-09).
need = b.disk_need_bytes
if need:
free = self.docker.free_bytes()
required = int(need * cfg.disk.factor + cfg.disk.headroom_bytes)
if free is not None and free < required:
raise Refusal(
"DISK_INSUFFICIENT",
f"docker data-root has {free // 2**20} MiB free, bundle needs {required // 2**20} MiB "
f"({need // 2**20} MiB x {cfg.disk.factor} + {cfg.disk.headroom_bytes // 2**20} MiB headroom)",
)
free = self.docker.free_bytes()
required = int(need * cfg.disk.factor + cfg.disk.headroom_bytes) if need else cfg.disk.headroom_bytes
if free is not None and free < required:
sized = (
f"bundle needs {required // 2**20} MiB "
f"({need // 2**20} MiB x {cfg.disk.factor} + {cfg.disk.headroom_bytes // 2**20} MiB headroom)"
if need
else f"a pull needs at least {required // 2**20} MiB headroom (bundle declares no size)"
)
raise Refusal(
"DISK_INSUFFICIENT",
f"image storage ({', '.join(self.docker.storage_paths())}) has {free // 2**20} MiB free, {sized}",
)
# -- credentials -------------------------------------------------------------------------------------
def _ensure_token(self) -> str:
+39 -4
View File
@@ -12,6 +12,10 @@ import time
from dataclasses import dataclass
from pathlib import Path
# containerd's default root: docker 29's image store lives here, often on another filesystem
# than DockerRootDir. Both are checked before a pull (see Docker.storage_paths).
CONTAINERD_ROOTS = ("/var/lib/containerd",)
log = logging.getLogger("monky-deployd.compose")
@@ -41,9 +45,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 +67,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 +78,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:
@@ -90,8 +104,23 @@ class Docker:
root = ""
return root or "/var/lib/docker"
def free_bytes(self, path: str | None = None) -> int | None:
p = path or self.data_root()
def storage_paths(self) -> list[str]:
"""Every filesystem a `compose pull` can fill.
docker 29 keeps IMAGE layers in the containerd image store (containerd's own root,
/var/lib/containerd by default), NOT under DockerRootDir. On a box where those two sit
on different filesystems, measuring only the data-root reports plenty of room while the
pull dies with "no space left on device" (env-dev-08, 2026-09-09: 93 GiB free on the
data-root, 2.8 GiB on the root filesystem that held containerd).
"""
paths = [self.data_root()]
for extra in CONTAINERD_ROOTS:
if os.path.isdir(extra):
paths.append(extra)
return paths
def _free_at(self, path: str) -> int | None:
p = path
while p and not os.path.exists(p):
p = os.path.dirname(p)
try:
@@ -100,6 +129,12 @@ class Docker:
return None
return st.f_bavail * st.f_frsize
def free_bytes(self, path: str | None = None) -> int | None:
"""Free bytes on `path`, or the TIGHTEST of the image-storage filesystems."""
paths = [path] if path else self.storage_paths()
seen = [v for v in (self._free_at(p) for p in paths) if v is not None]
return min(seen) if seen else None
def image_prune(self) -> None:
try:
self.run(["image", "prune", "-f"], timeout=300)
+13 -1
View File
@@ -15,7 +15,9 @@ from pathlib import Path
DEFAULT_CONFIG_PATH = "/etc/monky-deployd/config.yaml"
TRANSPORTS = ("sdk", "proxy", "system")
ENV_ID_RE = re.compile(r"^env-(dev|qa|stage|prod)-[0-9]{2,3}$|^(dev-env-2|prod-cedar)$")
SITES = ("cbs", "pdx")
# tenancy 0.7.x issues `fmt | pdx | roam` (doc 24 §1.4a); `cbs` is the deprecated alias of `fmt`
# that every row registered before 2026-09-08 still carries. Keep the two sets in step (deployd#3).
SITES = ("fmt", "cbs", "pdx", "roam")
class ConfigError(Exception):
@@ -175,6 +177,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 +196,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:
+80
View File
@@ -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
+29
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import http.client
import json
import logging
import os
import socket
import ssl
from dataclasses import dataclass
@@ -35,6 +36,27 @@ class TransportError(Exception):
class Transport:
name = "base"
def _check_identity_readable(self) -> None:
path = self.identity_path
try:
with open(path, "rb") as fh:
raw = fh.read()
except FileNotFoundError as exc:
raise TransportError(f"transport sdk: identity {path} does not exist") from exc
except PermissionError as exc:
raise TransportError(
f"transport sdk: identity {path} is not readable by this user "
f"(uid {os.geteuid()}) — the file is owned by the tunneller and rewritten on every "
"config refresh, which drops POSIX ACLs; add the agent's user to the file's group "
"(usually `ziti`) so the grant survives"
) from exc
except OSError as exc:
raise TransportError(f"transport sdk: identity {path} is unreadable: {exc}") from exc
try:
json.loads(raw)
except ValueError as exc:
raise TransportError(f"transport sdk: identity {path} is not valid JSON: {exc}") from exc
def connect(self, host: str, port: int, timeout: float) -> socket.socket: # pragma: no cover
raise NotImplementedError
@@ -87,6 +109,13 @@ class SdkTransport(Transport):
"transport sdk: the openziti module is not installed in this venv; "
"use transport: proxy (monky-deployd-proxy.service) or system"
) from exc
# openziti.load() does NOT raise on an unreadable or malformed identity: the C SDK logs
# "Failed to load Ziti Identity ...: configuration is invalid" and hands back a context
# that fails LATER, at dial, as a bare TypeError — which reads as a missing intercept or
# a policy gap and sends you hunting the mesh instead of the file (env-dev-08,
# 2026-09-09: ziti-edge-tunnel rewrote the identity and dropped the agent's ACL).
# So check the file ourselves first and name the real fault.
self._check_identity_readable()
try:
self._ctx = openziti.load(self.identity_path)
except Exception as exc:
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# monky-deployd: (re)grant the agent read access to the host's ziti identity file(s).
# Idempotent; safe to run at any time. Invoked by install.sh, the package postinstall and the
# monky-deployd-identity-acl.path unit (whenever the identity directory changes).
#
# Why a re-runnable script and not a one-time ACL: ziti-edge-tunnel re-creates the identity on
# every controller config update with mode 0600. On a file with an ACL that sets the mask to ---,
# so the named-user entry AND the ziti-group read both become ineffective. Only re-applying the
# entries after each rewrite keeps the agent alive.
set -eu
DIR="${1:-/opt/openziti/etc/identities}"
[ -d "$DIR" ] || exit 0
command -v setfacl >/dev/null 2>&1 || exit 0
setfacl -m u:monky-deployd:rx,m::rx "$DIR" 2>/dev/null || true
setfacl -d -m u:monky-deployd:r,m::r "$DIR" 2>/dev/null || true
for f in "$DIR"/*.json; do
[ -f "$f" ] || continue
setfacl -m u:monky-deployd:r,m::r "$f" 2>/dev/null || true
done
exit 0
+15 -4
View File
@@ -4,7 +4,7 @@
# T=<read-only GitLab deploy token, scope read_package_registry> # from the install kit / OpenBao
# curl -sSf -H "DEPLOY-TOKEN: $T" \
# https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.6/install.sh \
# | sudo bash -s -- --env env-qa-02 --site cbs --token "$T" --bootstrap-file bootstrap.jwt \
# | sudo bash -s -- --env env-qa-02 --site fmt --token "$T" --bootstrap-file bootstrap.jwt \
# [--transport sdk|proxy|system] [--version 0.1.6] [--enrol-jwt /path/monky-host.env-qa-02.jwt] \
# [--laptop] [--source gitlab|gitea] [--docker-data-root /home/docker-data]
#
@@ -77,7 +77,7 @@ done
[ -n "$SITE" ] || die "--site is required"
[[ "$ENV_ID" =~ ^env-(dev|qa|stage|prod)-[0-9]{2,3}$|^(dev-env-2|prod-cedar)$ ]] || die "env id $ENV_ID is not env-<tier>-<nn>"
SITE="${SITE,,}"
[[ "$SITE" =~ ^(cbs|pdx)$ ]] || die "site must be cbs|pdx"
[[ "$SITE" =~ ^(fmt|cbs|pdx|roam)$ ]] || die "site must be fmt|cbs|pdx|roam (tenancy issues fmt|pdx|roam; cbs = the deprecated alias of fmt)"
[[ "$TRANSPORT" =~ ^(sdk|proxy|system)$ ]] || die "transport must be sdk|proxy|system"
[[ "$SOURCE" =~ ^(gitlab|gitea)$ ]] || die "source must be gitlab|gitea"
# never let the token leak through xtrace / the environment of children
@@ -213,9 +213,20 @@ fi
# --- 4. config, ACLs, bootstrap grant -----------------------------------------------------------------
install -d -m 0750 -o root -g monky-deployd "$ETC"
install -d -m 0700 -o monky-deployd -g monky-deployd /var/lib/monky-deployd
setfacl -m u:monky-deployd:r "$IDENTITY"
setfacl -m u:monky-deployd:rx "$IDENTITY_DIR"
# The identity file is the agent's only credential for the mesh. ziti-edge-tunnel rewrites it on
# every controller config update and the rewrite DROPS these ACLs, so the durable grant is group
# membership (the file stays <ziti>:<ziti> 0640 across rewrites); the ACLs below still cover the
# window before the agent's next login and hosts where the file has another group.
# A default ACL on the directory carries the grant onto a freshly created identity file.
if getent group ziti >/dev/null; then usermod -a -G ziti monky-deployd || true; fi
setfacl -m u:monky-deployd:x /opt/openziti/etc 2>/dev/null || true
# The grant must OUTLIVE the tunneller: ziti-edge-tunnel re-creates the identity file on every
# controller config update with mode 0600, which sets the ACL mask to --- and defeats both the
# named-user entry and the ziti-group read (env-dev-08, 2026-09-11..13: two days of "identity is
# not readable" ticks while the tunnel was healthy). identity-acl.sh re-applies the entries and the
# mask; the .path unit re-runs it whenever the directory changes.
/usr/share/monky-deployd/identity-acl.sh "$IDENTITY_DIR"
systemctl enable --now monky-deployd-identity-acl.path 2>/dev/null || true
if [ -n "$BAO_CA" ]; then
install -m 0644 "$BAO_CA" "$ETC/openbao-ca.pem"
fi
+8
View File
@@ -33,6 +33,14 @@ contents:
dst: /usr/lib/systemd/system/monky-deployd.timer
- src: ./packaging/systemd/monky-deployd-proxy.service
dst: /usr/lib/systemd/system/monky-deployd-proxy.service
- src: ./packaging/systemd/monky-deployd-identity-acl.path
dst: /usr/lib/systemd/system/monky-deployd-identity-acl.path
- src: ./packaging/systemd/monky-deployd-identity-acl.service
dst: /usr/lib/systemd/system/monky-deployd-identity-acl.service
- src: ./packaging/bin/identity-acl.sh
dst: /usr/share/monky-deployd/identity-acl.sh
file_info:
mode: 0755
- src: ./packaging/monky-deployd.sysusers
dst: /usr/lib/sysusers.d/monky-deployd.conf
- src: ./packaging/monky-deployd.tmpfiles
+14 -1
View File
@@ -13,10 +13,23 @@ install -d -m 0700 -o monky-deployd -g monky-deployd /var/lib/monky-deployd
install -d -m 0750 -o root -g monky-deployd /etc/monky-deployd
# the agent drives docker compose: docker group membership (no root)
if getent group docker >/dev/null; then usermod -a -G docker monky-deployd || true; fi
# the agent reads the host's ziti identity (owned by the tunneller, mode 0640). ziti-edge-tunnel
# REWRITES that file whenever the controller sends a config update, and the rewrite drops any
# POSIX ACL granting the agent read — group membership is the grant that survives it.
# (env-dev-08, 2026-09-09: the agent went from applied to "no intercept" 6 minutes after a refresh.)
if getent group ziti >/dev/null; then usermod -a -G ziti monky-deployd || true; fi
# ...and group membership does NOT survive it either once the rewrite lands with mode 0600 (the ACL
# mask goes to ---). Re-apply the grant now and on every directory change (0.1.10, deployd#3 beat).
[ -x /usr/share/monky-deployd/identity-acl.sh ] && /usr/share/monky-deployd/identity-acl.sh /opt/openziti/etc/identities || true
# the venv is relocatable only to the path it was built at; refuse a broken interpreter early
/opt/monky-deployd/venv/bin/python -c 'import monky_deployd' || { echo "monky-deployd: venv unusable (python3 mismatch?)" >&2; exit 1; }
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
# do NOT enable the timer here: install.sh / the ansible role do it after the config exists
# do NOT enable the timer here: install.sh / the ansible role do it after the config exists.
# An UPGRADE is different — the timer is already enabled and must keep running, so restart the
# long-lived proxy unit onto the new code. `try-restart` is a no-op when it is not running.
systemctl try-restart monky-deployd-proxy.service 2>/dev/null || true
# an UPGRADE of a box that already has its config: arm the identity-ACL watcher
if [ -s /etc/monky-deployd/config.yaml ]; then systemctl enable --now monky-deployd-identity-acl.path 2>/dev/null || true; fi
fi
exit 0
+9
View File
@@ -1,5 +1,14 @@
#!/bin/sh
set -e
# dpkg calls the OLD package's prerm on an UPGRADE as well as on a removal, and rpm calls it with
# an install count. Disabling the timer unconditionally therefore stopped the agent on every
# upgrade and left it disabled — silently, because the box stays reachable and nothing else
# notices that check-ins have ceased (env-dev-01 and env-dev-08, 2026-09-09).
# dpkg: "$1" is `remove`, `upgrade <new-version>`, `deconfigure …` or `failed-upgrade`
# rpm : "$1" is the number of instances that will remain — 1 on upgrade, 0 on uninstall
case "${1:-}" in
upgrade | failed-upgrade | deconfigure | 1) exit 0 ;;
esac
if [ -d /run/systemd/system ]; then
systemctl disable --now monky-deployd.timer 2>/dev/null || true
systemctl disable --now monky-deployd-proxy.service 2>/dev/null || true
@@ -0,0 +1,11 @@
[Unit]
Description=Re-grant monky-deployd read access when the ziti identity directory changes
Documentation=file:/usr/share/doc/monky-deployd/OPERATIONS.md
[Path]
PathChanged=/opt/openziti/etc/identities
PathModified=/opt/openziti/etc/identities
Unit=monky-deployd-identity-acl.service
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
[Unit]
Description=Re-grant monky-deployd read access to the ziti identity file(s)
[Service]
Type=oneshot
ExecStart=/usr/share/monky-deployd/identity-acl.sh /opt/openziti/etc/identities
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "monky-deployd"
version = "0.1.6"
version = "0.1.10"
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"
+3
View File
@@ -52,6 +52,9 @@ def fake_docker(tmp_path, monkeypatch):
}
)
)
# the real agent also measures containerd's root; on a CI runner that path belongs to the
# runner's own docker and would make every disk assertion depend on the runner's free space.
monkeypatch.setattr("monky_deployd.compose.CONTAINERD_ROOTS", ())
monkeypatch.setenv("PATH", f"{FAKEBIN}:{os.environ['PATH']}")
monkeypatch.setenv("FAKE_DOCKER_LOG", str(log))
monkeypatch.setenv("FAKE_DOCKER_STATE", str(state))
+24
View File
@@ -237,6 +237,30 @@ def test_disk_refusal(bootstrapped, tenancy, fake_docker):
assert tenancy.leases == []
def test_disk_refusal_without_a_declared_size(bootstrapped, tenancy, fake_docker, monkeypatch):
"""A bundle that declares no size still has to clear the headroom floor.
Without the floor a nearly-full box passes the pre-flight and dies mid-pull with
containerd's "no space left on device", which reads as a registry fault (env-dev-08).
"""
cfg = bootstrapped
cfg.disk.headroom_bytes = 2 * 1024**3
monkeypatch.setattr("monky_deployd.compose.Docker.free_bytes", lambda self, path=None: 32 * 2**20)
tenancy.set_files(make_files())
assert tick(cfg) == EX_FAIL
detail = tenancy.reports[-1]["detail"]
assert "DISK_INSUFFICIENT" in detail and "declares no size" in detail
assert tenancy.leases == []
def test_ample_disk_without_a_declared_size_applies(bootstrapped, tenancy, fake_docker, monkeypatch):
cfg = bootstrapped
monkeypatch.setattr("monky_deployd.compose.Docker.free_bytes", lambda self, path=None: 500 * 2**30)
tenancy.set_files(make_files())
assert tick(cfg) == EX_OK
assert tenancy.reports[-1]["result"] == "applied"
def test_legacy_approle_lease_is_refused_loudly(bootstrapped, tenancy, fake_docker):
cfg = bootstrapped
tenancy.lease_shape = "approle"
+2
View File
@@ -79,6 +79,8 @@ def test_sdk_transport_uses_openziti_monkeypatch(monkeypatch, tmp_path):
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
# the transport pre-flights the identity file before handing it to the SDK
(tmp_path / "id.json").write_text('{"ztAPI": "https://example.invalid"}')
t = SdkTransport(str(tmp_path / "id.json"))
s = t.connect("127.0.0.1", srv.getsockname()[1], 2)
s.close()
+9
View File
@@ -74,6 +74,15 @@ def test_config_rejects_approle_and_unknown_keys():
c.from_dict({"env_id": "env-dev-06", "site": "sfo"})
def test_config_sites_match_tenancy():
"""tenancy 0.7.x registers `fmt | pdx | roam`; `cbs` stays as the deprecated alias every row
registered before 2026-09-08 carries (deployd#3: kits died with 'site must be cbs|pdx')."""
assert set(c.SITES) == {"fmt", "cbs", "pdx", "roam"}
for site in ("fmt", "roam", "pdx", "cbs"):
assert c.from_dict({"env_id": "env-dev-06", "site": site}).site == site
assert c.from_dict({"env_id": "env-dev-06", "site": "FMT"}).site == "fmt"
def test_sdk_identity_defaults_to_host_identity():
cfg = c.from_dict({"env_id": "env-dev-07", "site": "cbs"})
assert cfg.identity == "/opt/openziti/etc/identities/monky-host.env-dev-07.json"
+46
View File
@@ -0,0 +1,46 @@
"""The maintainer scripts must not stop the agent on an upgrade.
dpkg calls the OLD package's prerm on an upgrade as well as on a removal, and rpm calls it with
an install count. `systemctl disable --now` there stopped the agent on every upgrade and left it
disabled silently, because the box stays reachable and nothing else notices that check-ins have
ceased (env-dev-01 and env-dev-08, 2026-09-09).
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
PREREMOVE = Path(__file__).resolve().parents[1] / "packaging" / "scripts" / "preremove.sh"
def _run(arg: str, tmp_path: Path) -> list[str]:
"""Run preremove with a fake `systemctl` on PATH and report the calls it made."""
calls = tmp_path / "systemctl.log"
fake = tmp_path / "bin"
fake.mkdir(exist_ok=True)
(fake / "systemctl").write_text(f'#!/bin/sh\necho "$@" >> {calls}\nexit 0\n')
(fake / "systemctl").chmod(0o755)
env = {"PATH": f"{fake}:/usr/bin:/bin"}
r = subprocess.run(["sh", str(PREREMOVE), arg], env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return calls.read_text().splitlines() if calls.exists() else []
@pytest.mark.skipif(not Path("/run/systemd/system").is_dir(), reason="needs a systemd host to reach the disable branch")
@pytest.mark.parametrize("arg", ["remove", "0"])
def test_a_real_removal_disables_the_units(arg, tmp_path):
assert any("disable" in c for c in _run(arg, tmp_path))
@pytest.mark.parametrize("arg", ["upgrade", "failed-upgrade", "deconfigure", "1"])
def test_an_upgrade_leaves_the_units_alone(arg, tmp_path):
assert _run(arg, tmp_path) == []
def test_the_script_is_shell_clean():
assert shutil.which("sh")
subprocess.run(["sh", "-n", str(PREREMOVE)], check=True)
+53
View File
@@ -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()
+59
View File
@@ -0,0 +1,59 @@
"""Free-space accounting across the filesystems a pull can fill.
docker 29 keeps image layers in containerd's store, which is frequently on a different
filesystem than DockerRootDir. env-dev-08 (2026-09-09) had 93 GiB free on the data-root and
2.8 GiB on the filesystem containerd was writing to; the pull died with "no space left on
device" after the pre-flight had reported plenty of room.
"""
from __future__ import annotations
import monky_deployd.compose as compose_mod
from monky_deployd.compose import Docker
class _FixedDocker(Docker):
def __init__(self, root: str):
super().__init__()
self._root = root
def data_root(self) -> str:
return self._root
def test_storage_paths_include_containerd_root_when_present(tmp_path, monkeypatch):
root = tmp_path / "docker"
containerd = tmp_path / "containerd"
root.mkdir()
containerd.mkdir()
monkeypatch.setattr(compose_mod, "CONTAINERD_ROOTS", (str(containerd),))
assert _FixedDocker(str(root)).storage_paths() == [str(root), str(containerd)]
def test_storage_paths_skip_a_containerd_root_that_does_not_exist(tmp_path, monkeypatch):
root = tmp_path / "docker"
root.mkdir()
monkeypatch.setattr(compose_mod, "CONTAINERD_ROOTS", (str(tmp_path / "absent"),))
assert _FixedDocker(str(root)).storage_paths() == [str(root)]
def test_free_bytes_reports_the_tightest_filesystem(tmp_path, monkeypatch):
root = tmp_path / "docker"
containerd = tmp_path / "containerd"
root.mkdir()
containerd.mkdir()
monkeypatch.setattr(compose_mod, "CONTAINERD_ROOTS", (str(containerd),))
d = _FixedDocker(str(root))
monkeypatch.setattr(d, "_free_at", lambda p: 900 if p == str(root) else 5)
assert d.free_bytes() == 5
# an explicit path is still measured on its own
assert d.free_bytes(str(root)) == 900
def test_free_bytes_is_none_when_nothing_can_be_measured(tmp_path, monkeypatch):
root = tmp_path / "docker"
root.mkdir()
monkeypatch.setattr(compose_mod, "CONTAINERD_ROOTS", ())
d = _FixedDocker(str(root))
monkeypatch.setattr(d, "_free_at", lambda p: None)
assert d.free_bytes() is None
+54
View File
@@ -0,0 +1,54 @@
"""The SDK transport names an unusable identity file instead of failing at dial.
openziti.load() accepts an unreadable or malformed identity, logs "configuration is invalid"
at the C layer and returns a context that only fails when something dials through it as a
bare TypeError that reads like a missing intercept. env-dev-08 (2026-09-09) lost its identity
ACL to a tunneller rewrite and spent every tick reporting a mesh fault it did not have.
"""
from __future__ import annotations
import json
import pytest
from monky_deployd.transport import SdkTransport, TransportError
def _sdk(path):
t = SdkTransport(str(path))
return t
def test_missing_identity_is_named(tmp_path):
with pytest.raises(TransportError) as e:
_sdk(tmp_path / "absent.json")._check_identity_readable()
assert "does not exist" in str(e.value)
def test_malformed_identity_is_named(tmp_path):
p = tmp_path / "id.json"
p.write_text("{not json")
with pytest.raises(TransportError) as e:
_sdk(p)._check_identity_readable()
assert "not valid JSON" in str(e.value)
def test_unreadable_identity_points_at_the_group_grant(tmp_path, monkeypatch):
p = tmp_path / "id.json"
p.write_text(json.dumps({"ztAPI": "https://example.invalid"}))
def _denied(*a, **k):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr("builtins.open", _denied)
with pytest.raises(TransportError) as e:
_sdk(p)._check_identity_readable()
msg = str(e.value)
assert "not readable" in msg and "group" in msg
def test_a_good_identity_passes(tmp_path):
p = tmp_path / "id.json"
p.write_text(json.dumps({"ztAPI": "https://example.invalid"}))
_sdk(p)._check_identity_readable() # no raise