mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 05:36:15 +00:00
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:
@@ -0,0 +1,155 @@
|
||||
"""On-disk state under /var/lib/monky-deployd (0700): state.json, bao.token, the flock.
|
||||
|
||||
Everything is written atomically (tmp + fsync + rename) with mode 0600; the token file is the
|
||||
ONLY credential the agent keeps, and it is the agent's bearer to tenancy (ADR-0028 §C.C)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
HISTORY_MAX = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenMeta:
|
||||
accessor: str | None = None
|
||||
issued_at: float | None = None
|
||||
ttl_s: int | None = None
|
||||
renewable: bool | None = None
|
||||
grant_jti: str | None = None
|
||||
source: str | None = None # bootstrap | lease
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
env_id: str
|
||||
applied_sha: str | None = None
|
||||
applied_at: float | None = None
|
||||
last_checkin_at: float | None = None
|
||||
last_report_at: float | None = None
|
||||
last_result: str | None = None
|
||||
last_error: str | None = None
|
||||
last_action: str | None = None
|
||||
desired_sha: str | None = None
|
||||
history: list[str] = field(default_factory=list)
|
||||
token: TokenMeta = field(default_factory=TokenMeta)
|
||||
agent_version: str | None = None
|
||||
updated_at: float | None = None
|
||||
consecutive_failures: int = 0
|
||||
|
||||
def remember_applied(self, sha: str) -> None:
|
||||
self.applied_sha = sha
|
||||
self.applied_at = time.time()
|
||||
if sha in self.history:
|
||||
self.history.remove(sha)
|
||||
self.history.append(sha)
|
||||
del self.history[:-HISTORY_MAX]
|
||||
|
||||
def is_rollback(self, sha: str) -> bool:
|
||||
"""`sha` was applied before AND something newer has been applied since."""
|
||||
if sha not in self.history:
|
||||
return False
|
||||
return self.history.index(sha) < len(self.history) - 1
|
||||
|
||||
|
||||
def write_private(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def load(path: Path, env_id: str) -> State:
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
return State(env_id=env_id)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return State(env_id=env_id, last_error="state.json unreadable; reset")
|
||||
tok = raw.pop("token", None) or {}
|
||||
known = {k for k in State.__dataclass_fields__}
|
||||
st = State(**{k: v for k, v in raw.items() if k in known and k != "token"})
|
||||
st.token = TokenMeta(**{k: v for k, v in tok.items() if k in TokenMeta.__dataclass_fields__})
|
||||
if st.env_id != env_id:
|
||||
# state belongs to another env: never mix histories
|
||||
return State(env_id=env_id, last_error=f"state.json was for {st.env_id}; reset")
|
||||
return st
|
||||
|
||||
|
||||
def save(path: Path, st: State) -> None:
|
||||
st.updated_at = time.time()
|
||||
write_private(path, (json.dumps(asdict(st), indent=2, sort_keys=True) + "\n").encode())
|
||||
|
||||
|
||||
def read_token(path: Path) -> str | None:
|
||||
try:
|
||||
tok = path.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return tok or None
|
||||
|
||||
|
||||
def write_token(path: Path, token: str) -> None:
|
||||
write_private(path, token.encode())
|
||||
|
||||
|
||||
def delete(path: Path) -> bool:
|
||||
"""Remove a credential file. If the directory is not ours to write (/etc/monky-deployd is
|
||||
root-owned), truncating the file to zero bytes is just as final: an empty grant is no grant."""
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except PermissionError:
|
||||
try:
|
||||
with open(path, "wb"):
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class Lock:
|
||||
"""Non-blocking flock; a second concurrent tick exits quietly (the timer will fire again)."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fd: int | None = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
return False
|
||||
os.write(self._fd, str(os.getpid()).encode())
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._fd is not None:
|
||||
try:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
Reference in New Issue
Block a user