Files
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

218 lines
8.1 KiB
Python

"""`monky-deployd run [--once] [--prune]` · `status [--json]` · `bootstrap` · `version`."""
from __future__ import annotations
import argparse
import json
import logging
import os
import random
import signal
import sys
import time
from pathlib import Path
from monky_deployd import __version__
from monky_deployd import state as statemod
from monky_deployd.config import DEFAULT_CONFIG_PATH, Config, ConfigError, load
from monky_deployd.redact import install as install_redactor
EX_OK, EX_FAIL, EX_TEMPFAIL, EX_ENV_MISMATCH = 0, 1, 75, 78
_PRIO = {"DEBUG": 7, "INFO": 6, "WARNING": 4, "ERROR": 3, "CRITICAL": 2}
class _JournalFormatter(logging.Formatter):
"""sd-daemon priority prefix; journald strips it and files the level (no timestamp: journald has one)."""
def format(self, record: logging.LogRecord) -> str:
return f"<{_PRIO.get(record.levelname, 6)}>{record.name}: {record.getMessage()}"
def setup_logging(level: str = "INFO") -> None:
root = logging.getLogger()
root.handlers.clear()
h = logging.StreamHandler(sys.stderr)
if os.environ.get("JOURNAL_STREAM"):
h.setFormatter(_JournalFormatter())
else:
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root.addHandler(h)
root.setLevel(getattr(logging, level.upper(), logging.INFO))
install_redactor(root)
def _load(args) -> Config:
cfg = load(args.config)
if args.verbose:
cfg.log_level = "DEBUG"
return cfg
def cmd_run(args) -> int:
cfg = _load(args)
setup_logging(cfg.log_level)
from monky_deployd.agent import Agent
if args.once:
return Agent(cfg, prune=args.prune).run_once()
stop = {"now": False}
def _sig(*_):
stop["now"] = True
signal.signal(signal.SIGTERM, _sig)
signal.signal(signal.SIGINT, _sig)
log = logging.getLogger("monky-deployd")
log.info("loop mode (interval %ss%s)", cfg.interval_s, ", laptop" if cfg.laptop_mode else "")
rc = EX_OK
while not stop["now"]:
rc = Agent(cfg, prune=args.prune).run_once()
if rc == EX_ENV_MISMATCH:
return rc # no retry storm
delay = cfg.interval_s + random.uniform(0, min(10, cfg.interval_s / 6))
for _ in range(int(delay)):
if stop["now"]:
break
time.sleep(1)
return rc
def cmd_bootstrap(args) -> int:
cfg = _load(args)
setup_logging(cfg.log_level)
from monky_deployd.agent import Agent, NoCredentials
from monky_deployd.bao import BaoError
from monky_deployd.tenancy import EnvMismatch
from monky_deployd.transport import TransportError
agent = Agent(cfg)
if cfg.token_path.exists() and not args.force:
print(f"already bootstrapped ({cfg.token_path} exists); use --force to log in again")
return EX_OK
if args.force:
statemod.delete(cfg.token_path)
try:
agent.state = statemod.load(cfg.state_path, cfg.env_id)
agent._ensure_token()
statemod.save(cfg.state_path, agent.state)
except TransportError as exc:
print(f"network: {exc}", file=sys.stderr)
return EX_TEMPFAIL
except EnvMismatch as exc:
print(f"AGENT_ENV_MISMATCH: {exc.detail}", file=sys.stderr)
return EX_ENV_MISMATCH
except (NoCredentials, BaoError) as exc:
print(f"bootstrap failed: {exc}", file=sys.stderr)
return EX_FAIL
print(f"bootstrapped: token stored at {cfg.token_path} (accessor {agent.state.token.accessor})")
return EX_OK
def _fmt_ts(ts: float | None) -> str:
if not ts:
return "-"
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts)) + f" ({int(time.time() - ts)}s ago)"
def cmd_status(args) -> int:
cfg = _load(args)
st = statemod.load(cfg.state_path, cfg.env_id)
token_present = cfg.token_path.exists()
bootstrap_present = Path(cfg.bootstrap_path).exists()
current = Path(cfg.deploy_dir) / "current"
containers: list[dict] = []
try:
from monky_deployd.compose import Compose, Docker
d = Docker(cfg.docker_bin)
if d.available() and current.exists():
containers = [c.as_dict() for c in Compose(d, current, cfg.compose_project).ps()]
except Exception: # status must never crash on docker trouble
containers = []
healthy = bool(containers) and all(
c["state"] == "running" and c["health"] in ("", "healthy", "none") for c in containers
)
out = {
"env_id": cfg.env_id,
"site": cfg.site,
"transport": cfg.transport,
"agent_version": __version__,
"laptop_mode": cfg.laptop_mode,
"token_present": token_present,
"bootstrap_grant_present": bootstrap_present,
"token": {"accessor": st.token.accessor, "issued_at": st.token.issued_at, "source": st.token.source},
"applied_sha": st.applied_sha,
"desired_sha": st.desired_sha,
"in_sync": bool(st.applied_sha) and st.applied_sha == st.desired_sha,
"last_checkin_at": st.last_checkin_at,
"last_report_at": st.last_report_at,
"last_action": st.last_action,
"last_result": st.last_result,
"last_error": st.last_error,
"current_release": os.path.realpath(current) if current.exists() else None,
"containers": containers,
"healthy": healthy,
}
if args.json:
print(json.dumps(out, indent=2, sort_keys=True))
return EX_OK
print(
f"monky-deployd {__version__}{cfg.env_id} ({cfg.site}, transport {cfg.transport}{', laptop' if cfg.laptop_mode else ''})"
)
print(
f" credentials : token {'present' if token_present else 'ABSENT'}"
f"{' (accessor ' + st.token.accessor + ')' if st.token.accessor else ''}"
f"{'; bootstrap grant waiting' if bootstrap_present else ''}"
)
print(f" applied sha : {st.applied_sha or '-'}")
print(f" desired sha : {st.desired_sha or '-'} {'in sync' if out['in_sync'] else 'NOT in sync'}")
print(f" last checkin: {_fmt_ts(st.last_checkin_at)} action={st.last_action or '-'}")
print(f" last report : {_fmt_ts(st.last_report_at)} result={st.last_result or '-'}")
if st.last_error:
print(f" last error : {st.last_error}")
print(f" release : {out['current_release'] or '-'}")
if containers:
print(f" containers : {'healthy' if healthy else 'UNHEALTHY'}")
for c in containers:
print(f" - {c['name']}: {c['state']} {c['health'] or ''}".rstrip())
else:
print(" containers : none (docker unavailable or nothing deployed)")
return EX_OK if (token_present and (out["in_sync"] or not st.desired_sha)) else EX_FAIL
def cmd_version(_args) -> int:
print(__version__)
return EX_OK
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="monky-deployd", description="Monky backend pull agent (ADR-0028)")
p.add_argument("-c", "--config", default=os.environ.get("MONKY_DEPLOYD_CONFIG", DEFAULT_CONFIG_PATH))
p.add_argument("-v", "--verbose", action="store_true")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run", help="reconcile: --once for the timer/oneshot, otherwise loop")
r.add_argument("--once", action="store_true")
r.add_argument(
"--prune", action="store_true", help="after a successful apply, drop old releases + docker image prune"
)
r.set_defaults(fn=cmd_run)
s = sub.add_parser("status", help="what this box has applied vs what tenancy wants")
s.add_argument("--json", action="store_true")
s.set_defaults(fn=cmd_status)
b = sub.add_parser("bootstrap", help="log in to OpenBao with the install kit's grant; store the token")
b.add_argument("--force", action="store_true")
b.set_defaults(fn=cmd_bootstrap)
sub.add_parser("version").set_defaults(fn=cmd_version)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.fn(args))
except ConfigError as exc:
print(f"config error: {exc}", file=sys.stderr)
return EX_ENV_MISMATCH if "env_id" in str(exc) else EX_FAIL
except KeyboardInterrupt:
return 130