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
+147
View File
@@ -0,0 +1,147 @@
"""OpenBao over the mesh: jwt-tenancy login, token upkeep, KV-v2 reads of the env's own secrets.
The agent never sees a secret from tenancy: it logs in with the deploy grant
(`POST /v1/auth/<mount>/login {"role": "see-env", "jwt": <grant>}`) and reads
`<kv>/data/<env_id>/see/<name>` itself. The policy `see-env` is templated on the token's
entity alias (`user_claim=env_id`), so a manifest path for another env is refused here
BEFORE it could even be tried (`ManifestPathError`)."""
from __future__ import annotations
import logging
import urllib.parse
from dataclasses import dataclass
from monky_deployd.redact import REDACTOR
from monky_deployd.transport import HttpClient, HttpResponse, TransportError
log = logging.getLogger("monky-deployd.bao")
class BaoError(Exception):
def __init__(self, status: int, errors: list[str] | str, where: str = ""):
errs = errors if isinstance(errors, list) else [str(errors)]
super().__init__(f"{where or 'openbao'}: {status} {'; '.join(errs)[:300]}")
self.status = status
self.errors = errs
class ManifestPathError(Exception):
pass
@dataclass
class BaoToken:
client_token: str
accessor: str | None
ttl_s: int
renewable: bool
meta: dict
policies: list[str]
@property
def grant_jti(self) -> str | None:
return (self.meta or {}).get("grant_jti")
def kv_data_path(kv_mount: str, manifest_path: str, env_id: str) -> str:
"""`monky/<env>/see/<name>` or `monky/data/<env>/see/<name>` -> `/v1/monky/data/<env>/see/<name>`.
The env segment MUST equal this agent's env_id: the agent reads its own subtree and nothing
else (ADR-0028 §4 "what the agent may read")."""
parts = [p for p in manifest_path.strip("/").split("/") if p]
if len(parts) < 3 or parts[0] != kv_mount:
raise ManifestPathError(f"manifest path {manifest_path!r} is not under KV mount {kv_mount!r}")
rest = parts[1:]
if rest[0] == "data":
rest = rest[1:]
if len(rest) < 3 or rest[0] != env_id or rest[1] != "see":
raise ManifestPathError(f"manifest path {manifest_path!r} is outside {kv_mount}/data/{env_id}/see/")
if any(p in (".", "..") or not p for p in rest):
raise ManifestPathError(f"manifest path {manifest_path!r} is malformed")
return "/v1/" + "/".join([kv_mount, "data", *rest])
def _errors(resp: HttpResponse) -> list[str]:
try:
js = resp.json()
if isinstance(js, dict) and isinstance(js.get("errors"), list):
return [str(e) for e in js["errors"]] or [f"http {resp.status}"]
except TransportError:
pass
return [resp.text()[:200] or f"http {resp.status}"]
class BaoClient:
def __init__(self, http: HttpClient, *, auth_mount: str, role: str, kv_mount: str):
self.http = http
self.auth_mount = auth_mount
self.role = role
self.kv_mount = kv_mount
def _call(self, method: str, path: str, token: str | None = None, json_body=None) -> HttpResponse:
headers = {"X-Vault-Request": "true"}
if token:
headers["X-Vault-Token"] = token
resp = self.http.request(method, path, json_body=json_body, headers=headers)
if resp.status >= 400:
raise BaoError(resp.status, _errors(resp), f"{method} {path.split('?')[0]}")
return resp
# -- auth --------------------------------------------------------------------------------------
def login(self, grant_jwt: str, *, mount: str | None = None, role: str | None = None) -> BaoToken:
mount = mount or self.auth_mount
role = role or self.role
REDACTOR.add(grant_jwt)
js = self._call("POST", f"/v1/auth/{mount}/login", json_body={"role": role, "jwt": grant_jwt}).json()
auth = (js or {}).get("auth") or {}
tok = auth.get("client_token")
if not tok:
raise BaoError(200, "login returned no client_token", f"auth/{mount}/login")
REDACTOR.add(tok)
return BaoToken(
client_token=tok,
accessor=auth.get("accessor"),
ttl_s=int(auth.get("lease_duration") or 0),
renewable=bool(auth.get("renewable", False)),
meta=dict(auth.get("metadata") or {}),
policies=list(auth.get("token_policies") or auth.get("policies") or []),
)
def lookup_self(self, token: str) -> dict:
js = self._call("GET", "/v1/auth/token/lookup-self", token).json()
return dict((js or {}).get("data") or {})
def renew_self(self, token: str, increment_s: int | None = None) -> int:
body = {"increment": f"{increment_s}s"} if increment_s else {}
js = self._call("POST", "/v1/auth/token/renew-self", token, json_body=body).json()
return int(((js or {}).get("auth") or {}).get("lease_duration") or 0)
def revoke_self(self, token: str) -> None:
try:
self._call("POST", "/v1/auth/token/revoke-self", token, json_body={})
except BaoError as exc:
log.debug("revoke-self ignored: %s", exc)
# -- KV ----------------------------------------------------------------------------------------
def kv_read(self, token: str, manifest_path: str, env_id: str, version: int | None) -> str:
path = kv_data_path(self.kv_mount, manifest_path, env_id)
if version:
path += "?" + urllib.parse.urlencode({"version": int(version)})
js = self._call("GET", path, token).json() or {}
data = (js.get("data") or {}).get("data")
if not isinstance(data, dict) or not data:
raise BaoError(200, "empty secret", path.split("?")[0])
if "value" in data:
value = data["value"]
elif len(data) == 1:
value = next(iter(data.values()))
else:
raise BaoError(200, "secret has no 'value' key and is ambiguous", path.split("?")[0])
if not isinstance(value, str):
value = str(value)
REDACTOR.add(value)
got = ((js.get("data") or {}).get("metadata") or {}).get("version")
if version and got is not None and int(got) != int(version):
raise BaoError(200, f"version mismatch: wanted {version}, got {got}", path.split("?")[0])
return value