mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 06:16:16 +00:00
05724edde2
DD-0524 — PROTOCOL.md §Divergences bullets 1-2 said monky-tenancy `main` "still implements the AppRole lease and install kit" and that "the kit's generated config uses tenancy.base_url". Both were false when the page was published: tenancy !17 (288df791, merged 07:48Z) shipped AgentLeaseOut{env_id, login_jwt, ttl_s, mount, role, addr}, the ES256 grant signer and the JWKS sixteen minutes before v0.1.0 was tagged, and !22 (61bd0281) made the kit run install.sh with flags instead of writing a config. The two bullets are now dated "Resolved" notes; bullets 3-4 (report `detail`, X-Bundle-Sha) stand. DD-0526 — the lease example sent `reason` and received a nested `vault{}`; tenancy's AgentLease is `{env_id}` and AgentLeaseOut carries `addr` at the top level (no vault object). The example now shows tenancy's shapes (with a note that the agent still sends `reason` and tenancy ignores unknown fields), the checkin example gains `auth_mount`/`auth_role` so it is the full AgentVaultOut, tests/fakes.py emits `addr` the way tenancy does, and tenancy.py's docstring and lease() read `addr` first (the `vault{}` fallback is kept so an older fake or tenancy still leases). Verified against monky-tenancy app/schemas_backends.py at 1fd51454 (AgentLease 296-297, AgentLeaseOut 300-309, AgentVaultOut 278-283). Gates (local, py3.12): ruff format --check, ruff check, pytest 50 passed, bash -n packaging/install.sh. Doc-Drift: DD-0524 fixed Doc-Drift: DD-0526 fixed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AW3QqEpwLV69KHn24Re45Q
192 lines
6.8 KiB
Python
192 lines
6.8 KiB
Python
"""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): `{env_id, login_jwt, ttl_s, mount, role, addr}`. 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")
|
|
# shape of record (tenancy AgentLeaseOut): `addr` is top-level; a pre-0.1.x `vault{}` object
|
|
# is still read as a fallback so an older fake or tenancy does not break the lease
|
|
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=js.get("addr") or 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)
|