Files
monky-deployd/monky_deployd/compose.py
T
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

260 lines
9.3 KiB
Python

"""`docker compose` + a few `docker` facts. Everything shells out with a timeout and captured
output; nothing here ever sees a secret except the `.env` file compose reads by itself."""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
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")
class ComposeError(Exception):
def __init__(self, what: str, rc: int, output: str):
super().__init__(f"{what} failed (rc={rc})")
self.what, self.rc, self.output = what, rc, output
@dataclass
class Container:
name: str
state: str
health: str
exit_code: int | None = None
def as_dict(self) -> dict:
return {"name": self.name, "state": self.state, "health": self.health}
@property
def ok(self) -> bool:
if self.state == "running":
return self.health in ("", "healthy", "none")
if self.state == "exited":
return (self.exit_code or 0) == 0
return False
class Docker:
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
def run(self, args: list[str], *, cwd: str | None = None, timeout: int | None = None, check: bool = True) -> str:
cmd = [self.bin, *args]
what = _what(cmd)
log.debug("exec: %s", " ".join(cmd))
try:
p = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout or self.timeout_s,
env=self._env(),
)
except FileNotFoundError as exc:
raise ComposeError(what, 127, f"{self.bin} not found") from exc
except subprocess.TimeoutExpired as exc:
raise ComposeError(what, 124, f"timeout after {exc.timeout}s") from exc
out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "")
if check and p.returncode != 0:
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:
return self.run(["version", "--format", "{{.Server.Version}}"], timeout=20).strip() or None
except ComposeError:
return None
def compose_version(self) -> str | None:
try:
return self.run(["compose", "version", "--short"], timeout=20).strip() or None
except ComposeError:
return None
def data_root(self) -> str:
try:
root = self.run(["info", "--format", "{{.DockerRootDir}}"], timeout=20).strip()
except ComposeError:
root = ""
return root or "/var/lib/docker"
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:
st = os.statvfs(p or "/")
except OSError:
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)
except ComposeError as exc:
log.warning("image prune failed: %s", exc)
class Compose:
def __init__(self, docker: Docker, project_dir: Path, project_name: str):
self.docker = docker
self.project_dir = Path(project_dir)
self.project_name = project_name
def _base(self) -> list[str]:
return [
"compose",
"--project-name",
self.project_name,
"--project-directory",
str(self.project_dir),
]
def _with_files(self) -> list[str]:
args = self._base()
env_file = self.project_dir / ".env"
if env_file.exists():
args += ["--env-file", str(env_file)]
return args
def config_check(self) -> str:
return self.docker.run([*self._with_files(), "config", "--quiet"], cwd=str(self.project_dir), timeout=120)
def pull(self) -> str:
return self.docker.run([*self._with_files(), "pull", "--quiet"], cwd=str(self.project_dir))
def up(self) -> str:
return self.docker.run(
[*self._with_files(), "up", "-d", "--remove-orphans", "--quiet-pull"], cwd=str(self.project_dir)
)
def down(self, *, purge_volumes: bool) -> str:
args = [*self._base(), "down", "--remove-orphans"]
if purge_volumes:
args.append("-v")
return self.docker.run(args, cwd=str(self.project_dir) if self.project_dir.exists() else None, timeout=600)
def ps(self) -> list[Container]:
if not self.project_dir.exists():
return []
try:
out = self.docker.run(
[*self._base(), "ps", "-a", "--format", "json"], cwd=str(self.project_dir), timeout=60
)
except ComposeError as exc:
log.warning("compose ps failed: %s", exc)
return []
return parse_ps(out)
def logs_tail(self, lines: int = 80) -> str:
try:
return self.docker.run(
[*self._base(), "logs", "--no-color", "--tail", str(lines)], cwd=str(self.project_dir), timeout=60
)
except ComposeError as exc:
return exc.output
def wait_healthy(self, timeout_s: int, poll_s: float = 5.0) -> tuple[bool, list[Container]]:
deadline = time.monotonic() + timeout_s
containers: list[Container] = []
while True:
containers = self.ps()
if containers and all(c.ok for c in containers) and not any(c.health == "starting" for c in containers):
return True, containers
if any(c.state == "exited" and (c.exit_code or 0) != 0 for c in containers):
return False, containers
if time.monotonic() >= deadline:
return False, containers
time.sleep(poll_s)
def _what(cmd: list[str]) -> str:
"""`docker compose <sub>` / `docker <sub>` for error messages (compose global flags skipped)."""
if len(cmd) > 1 and cmd[1] == "compose":
rest = cmd[2:]
while rest and rest[0].startswith("--"):
rest = rest[2:]
return "docker compose " + (rest[0] if rest else "")
return " ".join(cmd[:2])
def parse_ps(out: str) -> list[Container]:
"""`compose ps --format json` is NDJSON on compose >= 2.21 and a JSON array before."""
text = out.strip()
if not text:
return []
rows: list[dict] = []
if text.startswith("["):
try:
rows = json.loads(text)
except json.JSONDecodeError:
rows = []
else:
for line in text.splitlines():
line = line.strip()
if line.startswith("{"):
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
out_list = []
for r in rows:
state = str(r.get("State") or r.get("state") or "").lower()
health = str(r.get("Health") or r.get("health") or "").lower()
code = r.get("ExitCode", r.get("exit_code"))
try:
code = int(code) if code is not None else None
except (TypeError, ValueError):
code = None
out_list.append(
Container(name=str(r.get("Name") or r.get("name") or "?"), state=state, health=health, exit_code=code)
)
return out_list