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