"""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 # "/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)