Files
monky-deployd/monky_deployd/compose.py
T
mdella 1c42e913a8 feat: monky-deployd v0.1.0 — pull agent over the mesh (ADR-0028)
Stdlib-only Python 3.12 agent for docker VMs and laptops: flock → checkin
(bearer = the agent's OpenBao token, bootstrapped from the install kit's
jwt-tenancy deploy grant) → action apply|none|down → bundle (sha256
verified) → refusal checks (unresolved ${VAR} names only, manifest paths
pinned to monky/data/<env>/see/, privileged/host-network, rollback, disk
need×1.5+headroom) → lease → POST /v1/auth/jwt-tenancy/login → KV reads →
.env 0600 → promote → compose pull/up → wait healthy → report; finally
renew-self / re-lease before max TTL, scrub. Exit 0/75/78/1. Redactor log
filter. Transports sdk (openziti) / proxy (ziti tunnel proxy 18443/18200) /
system. Laptop mode.

Packaging: hardened oneshot + 60 s timer + proxy unit, nfpm .deb with
/opt/monky-deployd/venv, install.sh for Ubuntu 26.04 (Gitea release
download, enrol, ACLs, bootstrap from stdin), ansible role skeleton for
osg1-07. CI: lint/test on every change; wheel (openziti on ubuntu:26.04) and
package (nfpm) allow_failure until runner egress is proven; GitLab release +
release:gitea on v* tags. Docs: README, PROTOCOL, OPERATIONS, CHANGELOG,
CLAUDE/AGENTS.

Divergence noted: monky-tenancy main (MR !15) still ships the AppRole lease
and kit; this agent implements the plan's Gate 1 RESULT (login_jwt, no
unwrap) and refuses an AppRole lease loudly (LEASE_SHAPE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLB7jieMNRkTsJ2epr4Ds1
2026-09-05 08:01:36 +00:00

225 lines
7.6 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
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):
self.bin = docker_bin
self.timeout_s = timeout_s
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={**os.environ, "COMPOSE_INTERACTIVE_NO_CLI": "1"},
)
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
# -- 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 free_bytes(self, path: str | None = None) -> int | None:
p = path or self.data_root()
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 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