# monky-deployd — CLAUDE.md ## What this is **monky-deployd** is the **on-box pull agent** for Monky backend environments on docker VMs and laptops (MONKY-ADR-0028). It dials **monky-tenancy** over the OpenZiti mesh with the box's own host identity, converges the box to the published bundle (`docker compose`), reads its secrets from **OpenBao** itself, and reports — the check-in is also the liveness heartbeat. It is the apply half of the backend lifecycle; the API/DB half is monky-tenancy (`/v1/backends*`, `/v1/agent/*`), the composition source is monky-deploy (renderer), the console is monky-mgmt-ui. k8s backends have no agent (ArgoCD applies). ## Vocabulary (hard rule) **backend**, **environment** (`env_id` = `env--`), **bundle**, **deploy grant**, **check-in**. Check-in returns `action ∈ apply|none|down`; a report carries `result ∈ applied|failed|down`. There is no `desired_sha="down"`, no "absent", no "up" result. Never "tenant" (the API repo's name is the one exception). ## Non-negotiables - **Stdlib only** in `monky_deployd/`. The only optional import is `openziti` (transport `sdk`), loaded lazily inside `transport.SdkTransport`. No PyYAML: `config.parse_yaml_subset` reads the config; keep the install kit / ansible template inside that subset. - **The agent never receives a secret from tenancy** and **never logs a value**. Every value read from OpenBao (and every token/grant) goes through `redact.REDACTOR.add()` the moment it exists; refusals and reports name variables, never values. Tests assert the report tail carries none. - **No AppRole, nothing to unwrap.** Auth is `POST /v1/auth/jwt-tenancy/login {"role":"see-env", "jwt":}`; the resulting Bao token is the bearer to tenancy. An AppRole-shaped lease is a loud `LEASE_SHAPE` failure, not a fallback. - **Refuse before you lease.** All bundle checks (sha, env, unresolved vars, manifest paths, privileged, rollback, disk) run before `POST /v1/agent/lease`, so a refused bundle costs no lease (5/h budget) and no OpenBao login. - **Exit codes are a contract** with the timer and the loop: 0 / 75 (temporary, retry) / 78 (`AGENT_ENV_MISMATCH`, never retried) / 1. `SuccessExitStatus=75` in the unit. - **Prod never purges.** `-v` is dropped on prod even if tenancy asks; the agent never decides state. - **Paths**: bearer at `/var/lib/monky-deployd/bao.token` (0600), grant at `/etc/monky-deployd/bootstrap.jwt` (consumed then deleted/truncated), releases under `/releases/` with a `current` symlink = the compose project directory. ## Layout | module | role | |---|---| | `cli.py` | argparse, journald-friendly logging (`` priority when `JOURNAL_STREAM`), `run/status/bootstrap/version` | | `agent.py` | the tick (`Agent.run_once`), action dispatch, refusal checks, token upkeep, staging/promote | | `config.py` | YAML-subset reader + `Config` dataclasses + validation | | `transport.py` | `sdk` / `proxy` / `system` transports + `HttpClient` (http.client with TLS SNI) | | `tenancy.py` | the four calls; error classes `Unauthenticated`, `EnvMismatch`, `RateLimited`, `LeaseShapeUnsupported` | | `bao.py` | login, lookup/renew/revoke-self, KV-v2 read, `kv_data_path` (env pinning) | | `bundle.py` | tar parse, `bundle_sha` (tenancy's formula), unresolved-var / privileged scans, `.env` rendering | | `compose.py` | `docker compose` wrapper, `ps --format json` parsing (NDJSON + array), health wait | | `state.py` | `state.json`, token file, atomic 0600 writes, `flock` | | `redact.py` | the log filter | ## Tests `pytest` is hermetic: `tests/fakes.py` runs a fake tenancy agent endpoint and a fake OpenBao as real HTTP servers on loopback (shapes of record, incl. env pinning, superseded grants, rate limits, the AppRole shape), and `tests/fakebin/docker` is a stub on `PATH` that records every invocation. No network, no docker daemon, no root. Add a test for every new refusal code. ## Gates before you push ```sh ruff format --check . || exit 1 ruff check . || exit 1 pytest || exit 1 bash -n packaging/install.sh || exit 1 systemd-analyze verify packaging/systemd/*.service # when available ``` ## Releasing Bump `monky_deployd.__version__` + `pyproject.toml` + `packaging/install.sh` `DEFAULT_VERSION` + `CHANGELOG.md`, merge to `main`, tag `vX.Y.Z` (protected `v*`). The tag pipeline builds the `.deb`, publishes the **GitLab generic package registry + release** the installer downloads from (public project; the Gitea name is split-horizon inside the estate, cbs/iac#102) and — with `GITEA_TOKEN` — the **Gitea release** (`install.sh --source gitea`, off-estate; `docs/OPERATIONS.md` has the manual recipe). `lint` refuses a tag whose version differs from `__version__`. ## What NOT to do - Don't add a dependency to the agent; don't import `openziti` at module top level. - Don't log, print or report a secret value, a token or a grant; don't put values in `state.json`. - Don't add an AppRole/unwrap path "for compatibility". - Don't apply when a check fails "with a warning"; refuse, report, exit 1. - Don't retry `AGENT_ENV_MISMATCH`; don't turn 75 into a busy loop (the timer is the retry). - Don't edit `*.ru.md` (doc-translator owns them); keep line 1 ``. - Cite decisions as `MONKY-ADR-NNNN`; don't restate them here.