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:
2026-09-05 08:01:36 +00:00
parent c72d6c0227
commit 1c42e913a8
48 changed files with 4932 additions and 64 deletions
+189
View File
@@ -0,0 +1,189 @@
"""The four agent calls on monky-tenancy's agent entrypoint (`/v1/agent/*`, MONKY-ADR-0028 §C.C).
Bearer = the agent's OpenBao token (from the `jwt-tenancy` login). Tenancy pins every call to
the token's `meta.env_id` (403 AGENT_ENV_MISMATCH -> exit 78, never retried) and refuses a token
whose deploy grant was superseded (401 AGENT_UNAUTHENTICATED -> re-bootstrap or re-run the kit).
Lease shape of record (Gate 1 v2, 2026-09-05): `{login_jwt, ttl_s, mount, role, vault}`. An
AppRole-era body (`wrapping_token`, `role_id`) is refused loudly — there is nothing to unwrap."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from monky_deployd.transport import HttpClient, HttpResponse, TransportError
log = logging.getLogger("monky-deployd.tenancy")
LOG_TAIL_MAX = 16 * 1024
class TenancyError(Exception):
def __init__(self, status: int, code: str, detail: str = ""):
super().__init__(f"{status} {code}: {detail}".rstrip(": "))
self.status, self.code, self.detail = status, code, detail
class Unauthenticated(TenancyError):
"""401 — the bearer is unknown, expired, revoked or its grant was superseded."""
class EnvMismatch(TenancyError):
"""403 AGENT_ENV_MISMATCH — this box's token is pinned to another env. Exit 78."""
class RateLimited(TenancyError):
def __init__(self, status: int, code: str, detail: str, retry_after: int):
super().__init__(status, code, detail)
self.retry_after = retry_after
class LeaseShapeUnsupported(TenancyError):
"""Tenancy answered with an AppRole lease; this agent only speaks the jwt-tenancy grant."""
@dataclass
class Vault:
addr: str | None = None
mount: str | None = None # the KV mount ("monky")
prefix: str | None = None # "<env>/see"
@dataclass
class Checkin:
env_id: str
action: str
desired_sha: str | None
purge_volumes: bool
bundle_url: str | None
checkin_interval_s: int
vault: Vault = field(default_factory=Vault)
@dataclass
class Lease:
login_jwt: str
ttl_s: int
mount: str
role: str
vault: Vault = field(default_factory=Vault)
def _error(resp: HttpResponse) -> TenancyError:
code, detail = "HTTP_ERROR", resp.text()[:200]
try:
js = resp.json()
if isinstance(js, dict):
code = str(js.get("code") or code)
detail = str(js.get("detail") or detail)
except TransportError:
pass
if resp.status == 401:
return Unauthenticated(resp.status, code, detail)
if resp.status == 403 and code == "AGENT_ENV_MISMATCH":
return EnvMismatch(resp.status, code, detail)
if resp.status == 429:
try:
ra = int(resp.headers.get("retry-after", "60"))
except ValueError:
ra = 60
return RateLimited(resp.status, code, detail, ra)
return TenancyError(resp.status, code, detail)
class TenancyClient:
def __init__(self, http: HttpClient, env_id: str, token: str):
self.http = http
self.env_id = env_id
self.token = token
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.token}"}
def _call(self, method: str, path: str, json_body=None) -> HttpResponse:
resp = self.http.request(method, path, json_body=json_body, headers=self._headers())
if resp.status >= 400:
raise _error(resp)
return resp
def checkin(
self,
*,
agent_version: str,
applied_sha: str | None,
host: dict | None,
containers: list[dict] | None,
) -> Checkin:
body = {
"env_id": self.env_id,
"agent_version": agent_version,
"applied_sha": applied_sha,
"host": host,
"containers": containers,
}
js = self._call("POST", "/v1/agent/checkin", body).json() or {}
action = js.get("action")
if action not in ("apply", "none", "down"):
raise TenancyError(200, "BAD_CHECKIN", f"unknown action {action!r}")
v = js.get("vault") or {}
return Checkin(
env_id=js.get("env_id") or self.env_id,
action=action,
desired_sha=js.get("desired_sha"),
purge_volumes=bool(js.get("purge_volumes", False)),
bundle_url=js.get("bundle_url"),
checkin_interval_s=int(js.get("checkin_interval_s") or 60),
vault=Vault(addr=v.get("addr"), mount=v.get("mount"), prefix=v.get("prefix")),
)
def bundle(self, url: str) -> tuple[bytes, str | None]:
"""The tar + the sha tenancy says it is (X-Bundle-Sha / X-Bundle-Sha256)."""
resp = self._call("GET", url)
ctype = resp.headers.get("content-type", "")
if "tar" not in ctype:
raise TenancyError(resp.status, "BAD_BUNDLE", f"unexpected content-type {ctype!r}")
sha = resp.headers.get("x-bundle-sha256") or resp.headers.get("x-bundle-sha")
return resp.body, sha
def lease(self, reason: str = "apply") -> Lease:
js = self._call("POST", "/v1/agent/lease", {"env_id": self.env_id, "reason": reason}).json() or {}
if "login_jwt" not in js:
if "wrapping_token" in js or "role_id" in js:
raise LeaseShapeUnsupported(
200,
"LEASE_SHAPE",
"tenancy issued an AppRole lease (wrapping_token/role_id); monky-deployd "
"requires the jwt-tenancy deploy grant {login_jwt, ttl_s, mount, role} "
"(ADR-0028 amendment 2026-09-05)",
)
raise TenancyError(200, "LEASE_SHAPE", "lease response carries no login_jwt")
v = js.get("vault") or {}
return Lease(
login_jwt=str(js["login_jwt"]),
ttl_s=int(js.get("ttl_s") or 3600),
mount=str(js.get("mount") or "jwt-tenancy"),
role=str(js.get("role") or "see-env"),
vault=Vault(addr=v.get("addr"), mount=v.get("mount"), prefix=v.get("prefix")),
)
def report(
self,
*,
sha: str | None,
result: str,
log_tail: str | None,
containers: list[dict] | None = None,
detail: str | None = None,
) -> None:
assert result in ("applied", "failed", "down")
body = {
"env_id": self.env_id,
"sha": sha,
"result": result,
"log_tail": (log_tail or "")[-LOG_TAIL_MAX:] or None,
"containers": containers,
}
if detail:
body["detail"] = detail[:500]
self._call("POST", "/v1/agent/report", body)