mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 03:36:16 +00:00
1c42e913a8
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
190 lines
6.5 KiB
Python
190 lines
6.5 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): `{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)
|