mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 07:16: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
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
"""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
|