8 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
23 changed files with 422 additions and 22 deletions
+49
View File
@@ -1,6 +1,55 @@
<!-- xlate:verbatim-fences --> <!-- xlate:verbatim-fences -->
# Changelog # 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) ## 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 - **No hand `docker login` on a new box** (monky-design-docs !225, doc 24 §4a). A manifest entry marked
+1 -1
View File
@@ -3,7 +3,7 @@
# simple lists, comments. Keys not listed here are a config error. # 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 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) 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) identity: /opt/openziti/etc/identities/monky-host.env-qa-02.json # the box's host identity (read via ACL)
+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.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` | | `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`) | | `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 ```sh
systemctl status monky-deployd.timer monky-deployd.service 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`, 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}`, `/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), `/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 ## 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=none desired=… applied=…` then `healthy; heartbeat reported` | converged |
| `checkin: action=apply …``read 3 secret(s): GEMINI_API_KEY, …``promoted release …``applied …` | a deploy | | `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: 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 | | `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 | | `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_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) | | `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 | | `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`, 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.""" reports. Stdlib only; the optional `openziti` SDK is the `sdk` transport."""
__version__ = "0.1.7" __version__ = "0.1.10"
+17 -9
View File
@@ -353,16 +353,24 @@ class Agent:
raise Refusal("PRIVILEGED_REFUSED", ", ".join(findings) + " (bundle.json allow_privileged is not set)") 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"): 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") 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 need = b.disk_need_bytes
if need: free = self.docker.free_bytes()
free = self.docker.free_bytes() required = int(need * cfg.disk.factor + cfg.disk.headroom_bytes) if need else cfg.disk.headroom_bytes
required = int(need * cfg.disk.factor + cfg.disk.headroom_bytes) if free is not None and free < required:
if free is not None and free < required: sized = (
raise Refusal( f"bundle needs {required // 2**20} MiB "
"DISK_INSUFFICIENT", f"({need // 2**20} MiB x {cfg.disk.factor} + {cfg.disk.headroom_bytes // 2**20} MiB headroom)"
f"docker data-root has {free // 2**20} MiB free, bundle needs {required // 2**20} MiB " if need
f"({need // 2**20} MiB x {cfg.disk.factor} + {cfg.disk.headroom_bytes // 2**20} MiB headroom)", 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 ------------------------------------------------------------------------------------- # -- credentials -------------------------------------------------------------------------------------
def _ensure_token(self) -> str: def _ensure_token(self) -> str:
+27 -2
View File
@@ -12,6 +12,10 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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") log = logging.getLogger("monky-deployd.compose")
@@ -100,8 +104,23 @@ class Docker:
root = "" root = ""
return root or "/var/lib/docker" return root or "/var/lib/docker"
def free_bytes(self, path: str | None = None) -> int | None: def storage_paths(self) -> list[str]:
p = path or self.data_root() """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): while p and not os.path.exists(p):
p = os.path.dirname(p) p = os.path.dirname(p)
try: try:
@@ -110,6 +129,12 @@ class Docker:
return None return None
return st.f_bavail * st.f_frsize 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: def image_prune(self) -> None:
try: try:
self.run(["image", "prune", "-f"], timeout=300) self.run(["image", "prune", "-f"], timeout=300)
+3 -1
View File
@@ -15,7 +15,9 @@ from pathlib import Path
DEFAULT_CONFIG_PATH = "/etc/monky-deployd/config.yaml" DEFAULT_CONFIG_PATH = "/etc/monky-deployd/config.yaml"
TRANSPORTS = ("sdk", "proxy", "system") TRANSPORTS = ("sdk", "proxy", "system")
ENV_ID_RE = re.compile(r"^env-(dev|qa|stage|prod)-[0-9]{2,3}$|^(dev-env-2|prod-cedar)$") 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): class ConfigError(Exception):
+29
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import http.client import http.client
import json import json
import logging import logging
import os
import socket import socket
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
@@ -35,6 +36,27 @@ class TransportError(Exception):
class Transport: class Transport:
name = "base" 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 def connect(self, host: str, port: int, timeout: float) -> socket.socket: # pragma: no cover
raise NotImplementedError raise NotImplementedError
@@ -87,6 +109,13 @@ class SdkTransport(Transport):
"transport sdk: the openziti module is not installed in this venv; " "transport sdk: the openziti module is not installed in this venv; "
"use transport: proxy (monky-deployd-proxy.service) or system" "use transport: proxy (monky-deployd-proxy.service) or system"
) from exc ) 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: try:
self._ctx = openziti.load(self.identity_path) self._ctx = openziti.load(self.identity_path)
except Exception as exc: 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 # T=<read-only GitLab deploy token, scope read_package_registry> # from the install kit / OpenBao
# curl -sSf -H "DEPLOY-TOKEN: $T" \ # curl -sSf -H "DEPLOY-TOKEN: $T" \
# https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.6/install.sh \ # 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] \ # [--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] # [--laptop] [--source gitlab|gitea] [--docker-data-root /home/docker-data]
# #
@@ -77,7 +77,7 @@ done
[ -n "$SITE" ] || die "--site is required" [ -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>" [[ "$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="${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" [[ "$TRANSPORT" =~ ^(sdk|proxy|system)$ ]] || die "transport must be sdk|proxy|system"
[[ "$SOURCE" =~ ^(gitlab|gitea)$ ]] || die "source must be gitlab|gitea" [[ "$SOURCE" =~ ^(gitlab|gitea)$ ]] || die "source must be gitlab|gitea"
# never let the token leak through xtrace / the environment of children # never let the token leak through xtrace / the environment of children
@@ -213,9 +213,20 @@ fi
# --- 4. config, ACLs, bootstrap grant ----------------------------------------------------------------- # --- 4. config, ACLs, bootstrap grant -----------------------------------------------------------------
install -d -m 0750 -o root -g monky-deployd "$ETC" install -d -m 0750 -o root -g monky-deployd "$ETC"
install -d -m 0700 -o monky-deployd -g monky-deployd /var/lib/monky-deployd install -d -m 0700 -o monky-deployd -g monky-deployd /var/lib/monky-deployd
setfacl -m u:monky-deployd:r "$IDENTITY" # The identity file is the agent's only credential for the mesh. ziti-edge-tunnel rewrites it on
setfacl -m u:monky-deployd:rx "$IDENTITY_DIR" # 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 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 if [ -n "$BAO_CA" ]; then
install -m 0644 "$BAO_CA" "$ETC/openbao-ca.pem" install -m 0644 "$BAO_CA" "$ETC/openbao-ca.pem"
fi fi
+8
View File
@@ -33,6 +33,14 @@ contents:
dst: /usr/lib/systemd/system/monky-deployd.timer dst: /usr/lib/systemd/system/monky-deployd.timer
- src: ./packaging/systemd/monky-deployd-proxy.service - src: ./packaging/systemd/monky-deployd-proxy.service
dst: /usr/lib/systemd/system/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 - src: ./packaging/monky-deployd.sysusers
dst: /usr/lib/sysusers.d/monky-deployd.conf dst: /usr/lib/sysusers.d/monky-deployd.conf
- src: ./packaging/monky-deployd.tmpfiles - 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 install -d -m 0750 -o root -g monky-deployd /etc/monky-deployd
# the agent drives docker compose: docker group membership (no root) # 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 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 # 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; } /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 if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true 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 fi
exit 0 exit 0
+9
View File
@@ -1,5 +1,14 @@
#!/bin/sh #!/bin/sh
set -e 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 if [ -d /run/systemd/system ]; then
systemctl disable --now monky-deployd.timer 2>/dev/null || true systemctl disable --now monky-deployd.timer 2>/dev/null || true
systemctl disable --now monky-deployd-proxy.service 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] [project]
name = "monky-deployd" name = "monky-deployd"
version = "0.1.7" version = "0.1.10"
description = "Monky backend pull agent: checkin -> bundle -> lease -> OpenBao -> docker compose -> report, over the ziti mesh (MONKY-ADR-0028)" description = "Monky backend pull agent: checkin -> bundle -> lease -> OpenBao -> docker compose -> report, over the ziti mesh (MONKY-ADR-0028)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" 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("PATH", f"{FAKEBIN}:{os.environ['PATH']}")
monkeypatch.setenv("FAKE_DOCKER_LOG", str(log)) monkeypatch.setenv("FAKE_DOCKER_LOG", str(log))
monkeypatch.setenv("FAKE_DOCKER_STATE", str(state)) 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 == [] 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): def test_legacy_approle_lease_is_refused_loudly(bootstrapped, tenancy, fake_docker):
cfg = bootstrapped cfg = bootstrapped
tenancy.lease_shape = "approle" 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 = socket.socket()
srv.bind(("127.0.0.1", 0)) srv.bind(("127.0.0.1", 0))
srv.listen(1) 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")) t = SdkTransport(str(tmp_path / "id.json"))
s = t.connect("127.0.0.1", srv.getsockname()[1], 2) s = t.connect("127.0.0.1", srv.getsockname()[1], 2)
s.close() 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"}) 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(): def test_sdk_identity_defaults_to_host_identity():
cfg = c.from_dict({"env_id": "env-dev-07", "site": "cbs"}) cfg = c.from_dict({"env_id": "env-dev-07", "site": "cbs"})
assert cfg.identity == "/opt/openziti/etc/identities/monky-host.env-dev-07.json" 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)
+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