mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 05:36:15 +00:00
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
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""`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
|
||||
Reference in New Issue
Block a user