mirror of
https://scm.tikali.ai/tikali/applications/monky/monky-deployd.git
synced 2026-09-18 08:16:17 +00:00
Compare commits
6 Commits
6e8b2bf41d
...
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9dcebd7e | |||
| b25c6b3b8b | |||
| 35949a9246 | |||
| a30014a096 | |||
| 47c3840c95 | |||
| d0153dd71c |
@@ -1,6 +1,26 @@
|
|||||||
<!-- xlate:verbatim-fences -->
|
<!-- xlate:verbatim-fences -->
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.7 — registry credential + a pull error you can read (2026-09-08)
|
||||||
|
|
||||||
|
- **No hand `docker login` on a new box** (monky-design-docs !225, doc 24 §4a). A manifest entry marked
|
||||||
|
`use: registry-auth` is the Harbor pull credential, not an env var: it is written to
|
||||||
|
`<state_dir>/docker/config.json` (0600, in a directory the agent owns) and the docker CLI is pointed at it
|
||||||
|
with an explicit **`DOCKER_CONFIG`**. That last part is the trap — the unit runs as `monky-deployd`, so a
|
||||||
|
`docker login` by a human or by root is invisible to the agent and looks exactly like no credential at all
|
||||||
|
(env-dev-01, 2026-09-08). Both seeded shapes are accepted: a JSON object, or `username:password` with the
|
||||||
|
registry from the new `registry_host` config.
|
||||||
|
- **`compose pull` failures carry the registry's own message** instead of `rc=1`. "no basic auth
|
||||||
|
credentials", "manifest unknown" and DNS failures now reach the journal and the report to tenancy.
|
||||||
|
|
||||||
|
## 0.1.6 — 2026-09-07
|
||||||
|
|
||||||
|
- **A valid lease token is reused across applies.** Every apply requested a new lease, so a failing deploy
|
||||||
|
retried by the 60 s timer burned tenancy's 5-leases-per-hour budget and then failed on
|
||||||
|
`LEASE_RATE_LIMITED` forever (env-qa-02 pilot). Now: reuse a lease-derived token while `lookup-self`
|
||||||
|
says it is valid; swap the bootstrap token for a lease once; and if tenancy rate-limits the lease while a
|
||||||
|
working token exists, apply with it and defer the swap instead of failing the deploy.
|
||||||
|
|
||||||
## 0.1.5 — 2026-09-07
|
## 0.1.5 — 2026-09-07
|
||||||
|
|
||||||
- **`tenancy.port` defaults to 443 everywhere** — the ansible role default and the `TenancyCfg` default now
|
- **`tenancy.port` defaults to 443 everywhere** — the ansible role default and the `TenancyCfg` default now
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ you the enrolment JWT, a one-time **bootstrap deploy grant** and the read-only *
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
T=<deploy token> # read-only GitLab deploy token (read_package_registry); the kit carries it
|
T=<deploy token> # read-only GitLab deploy token (read_package_registry); the kit carries it
|
||||||
curl -sSf -H "DEPLOY-TOKEN: $T" https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.5/install.sh \
|
curl -sSf -H "DEPLOY-TOKEN: $T" https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.6/install.sh \
|
||||||
| sudo bash -s -- --env env-qa-02 --site cbs --token "$T" --bootstrap-file bootstrap.jwt --enrol-jwt ./monky-host.env-qa-02.jwt
|
| sudo bash -s -- --env env-qa-02 --site cbs --token "$T" --bootstrap-file bootstrap.jwt --enrol-jwt ./monky-host.env-qa-02.jwt
|
||||||
# [--transport sdk|proxy|system] [--version 0.1.5] [--docker-data-root /home/docker-data] [--laptop] [--bao-ca openbao-ca.pem] [--source gitlab|gitea]
|
# [--transport sdk|proxy|system] [--version 0.1.6] [--docker-data-root /home/docker-data] [--laptop] [--bao-ca openbao-ca.pem] [--source gitlab|gitea]
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` installs `ziti-edge-tunnel` (OpenZiti `jammy` suite) and `docker-compose-plugin` if
|
`install.sh` installs `ziti-edge-tunnel` (OpenZiti `jammy` suite) and `docker-compose-plugin` if
|
||||||
@@ -98,9 +98,9 @@ monky-deployd version
|
|||||||
## Security model
|
## Security model
|
||||||
|
|
||||||
- **Identity = the box's ziti host identity.** Only identities with `#monky-deploy-agent` can dial
|
- **Identity = the box's ziti host identity.** Only identities with `#monky-deploy-agent` can dial
|
||||||
tenancy's agent entrypoint; `#openbao-client` reaches OpenBao — the broker does not add either
|
tenancy's agent entrypoint; `#openbao-client` reaches OpenBao — tenancy sends both to the broker
|
||||||
attr yet (ADR-0028 addendum; see PROTOCOL.md §Where and how), so today an operator adds them on
|
as `host_extra_attrs` at register (monky-tenancy !29 / monky-ziti `ac258352`, 2026-09-07), so a
|
||||||
the controller after enrolment. The agent reads the identity
|
backend registered since then dials without hand-added attrs (see PROTOCOL.md §Where and how). The agent reads the identity
|
||||||
through an ACL (`setfacl -m u:monky-deployd:r`), never owns it.
|
through an ACL (`setfacl -m u:monky-deployd:r`), never owns it.
|
||||||
- **The bearer to tenancy is the agent's own OpenBao token**, minted by OpenBao from a
|
- **The bearer to tenancy is the agent's own OpenBao token**, minted by OpenBao from a
|
||||||
tenancy-signed ES256 deploy grant (`aud openbao-see-env`, `kind deploy-grant`, 1 h; its `jti`
|
tenancy-signed ES256 deploy grant (`aud openbao-see-env`, `kind deploy-grant`, 1 h; its `jti`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ template → optional openbao-ca PEM → optional `monky-deployd-proxy.service`
|
|||||||
|
|
||||||
| var | note |
|
| var | note |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `monky_deployd_version` | pinned release, e.g. `0.1.5` |
|
| `monky_deployd_version` | pinned release, e.g. `0.1.6` |
|
||||||
| `monky_deployd_download_token` | **vaulted**: GitLab deploy token, scope `read_package_registry` only (revocable) — the registry is private; seeded in OpenBao at `monky/monky-tenancy/deployd-download` key `token` (path/key are the operator's choice). Empty = no header (only works with the Gitea `base_url`) |
|
| `monky_deployd_download_token` | **vaulted**: GitLab deploy token, scope `read_package_registry` only (revocable) — the registry is private; seeded in OpenBao at `monky/monky-tenancy/deployd-download` key `token` (path/key are the operator's choice). Empty = no header (only works with the Gitea `base_url`) |
|
||||||
| `monky_deployd_env_id` / `_site` / `_transport` | per host (`env-dev-06`, `cbs`, `sdk`) |
|
| `monky_deployd_env_id` / `_site` / `_transport` | per host (`env-dev-06`, `cbs`, `sdk`) |
|
||||||
| `monky_deployd_bootstrap_grant` | tenancy-minted deploy grant (1 h) — `ansible-vault` or a lookup at play time; empty keeps the existing token |
|
| `monky_deployd_bootstrap_grant` | tenancy-minted deploy grant (1 h) — `ansible-vault` or a lookup at play time; empty keeps the existing token |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
# monky_deployd — install and configure the Monky backend pull agent (MONKY-ADR-0028 §D).
|
# monky_deployd — install and configure the Monky backend pull agent (MONKY-ADR-0028 §D).
|
||||||
# Copy this role into osg1-07 (roles/monky_deployd) and roll to env-dev-06..09 after the pilot.
|
# Copy this role into osg1-07 (roles/monky_deployd) and roll to env-dev-06..09 after the pilot.
|
||||||
monky_deployd_version: "0.1.5"
|
monky_deployd_version: "0.1.6"
|
||||||
monky_deployd_deb: "monky-deployd_{{ monky_deployd_version }}_amd64.deb"
|
monky_deployd_deb: "monky-deployd_{{ monky_deployd_version }}_amd64.deb"
|
||||||
# PRIMARY download = the GitLab project's generic package registry on scm.tikali.ai. Inside the estate
|
# PRIMARY download = the GitLab project's generic package registry on scm.tikali.ai. Inside the estate
|
||||||
# gitea.cbs.tikali.net is split-horizon to jump1's RED EIP (10.10.0.175, no HTTP ingress), so backend
|
# gitea.cbs.tikali.net is split-horizon to jump1's RED EIP (10.10.0.175, no HTTP ingress), so backend
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
# /etc/monky-deployd/config.yaml — monky-deployd v0.1.5 (MONKY-ADR-0028 §D)
|
# /etc/monky-deployd/config.yaml — monky-deployd v0.1.6 (MONKY-ADR-0028 §D)
|
||||||
# Written by packaging/install.sh (or the ansible role monky_deployd). YAML *subset*: maps, scalars,
|
# Written by packaging/install.sh (or the ansible role monky_deployd). YAML *subset*: maps, scalars,
|
||||||
# simple lists, comments. Keys not listed here are a config error.
|
# simple lists, comments. Keys not listed here are a config error.
|
||||||
|
|
||||||
@@ -44,3 +44,7 @@ laptop_mode: false # true: offline exits 0 quietly; run withou
|
|||||||
# compose_project: monky-env-qa-02 # docker compose project name
|
# compose_project: monky-env-qa-02 # docker compose project name
|
||||||
# docker_bin: docker
|
# docker_bin: docker
|
||||||
# log_level: INFO
|
# log_level: INFO
|
||||||
|
|
||||||
|
# The registry the bundle pulls from. Used only when the seeded pull credential is a bare
|
||||||
|
# `username:password` (a JSON credential names its own registry). doc 24 §4a.
|
||||||
|
registry_host: harbor.tikali.net
|
||||||
|
|||||||
+4
-5
@@ -15,11 +15,10 @@ The agent side of the backend lifecycle agent protocol — [MONKY-ADR-0028](http
|
|||||||
`state/overlay/configs.json` — the authority for the port); the sidecar's `host.v1` forwards to
|
`state/overlay/configs.json` — the authority for the port); the sidecar's `host.v1` forwards to
|
||||||
`127.0.0.1:8081` inside the pod. `tenancy.port` in the config, `install.sh` and the ansible role all
|
`127.0.0.1:8081` inside the pod. `tenancy.port` in the config, `install.sh` and the ansible role all
|
||||||
default to 443 (0.1.5). Only host identities carrying `#monky-deploy-agent` may dial
|
default to 443 (0.1.5). Only host identities carrying `#monky-deploy-agent` may dial
|
||||||
(`monky.tenancy.deploy.dial`, openziti). Adding that attr (and `#openbao-client`) to
|
(`monky.tenancy.deploy.dial`, openziti). Tenancy sends that attr and `#openbao-client` to the
|
||||||
`monky-host.<env>` at kit reveal is the ADR-0028 addendum and is **not implemented in monky-ziti
|
broker as `host_extra_attrs` when it registers an agent-backed backend (monky-tenancy !29,
|
||||||
yet** (its host template `app/fabric.py` `host_identity_attrs` does not carry it; openziti
|
monky-ziti `ac258352` — both 2026-09-07), so `monky-host.<env>` carries them from creation;
|
||||||
`docs/services.md` §Platform services records when it does) — until then an operator adds both
|
identities created before that (env-qa-02's) had them added on the controller by hand.
|
||||||
attrs on the controller by hand after enrolment.
|
|
||||||
- **Transport:** plain HTTP inside the mesh (`transport: sdk` dials by service name;
|
- **Transport:** plain HTTP inside the mesh (`transport: sdk` dials by service name;
|
||||||
`proxy` → `127.0.0.1:18443`). OpenBao is the existing `openbao` ziti service (`#openbao-client`),
|
`proxy` → `127.0.0.1:18443`). OpenBao is the existing `openbao` ziti service (`#openbao-client`),
|
||||||
dialled as `https://bao.cbs.tikali.net:8200` (an intercept name), TLS validated against the
|
dialled as `https://bao.cbs.tikali.net:8200` (an intercept name), TLS validated against the
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ Dials monky-tenancy over the mesh with the box's host identity, fetches the rend
|
|||||||
leases a deploy grant, logs in to OpenBao, reads its own secrets, runs `docker compose`,
|
leases a deploy grant, logs in to OpenBao, reads its own secrets, runs `docker compose`,
|
||||||
reports. Stdlib only; the optional `openziti` SDK is the `sdk` transport."""
|
reports. Stdlib only; the optional `openziti` SDK is the `sdk` transport."""
|
||||||
|
|
||||||
__version__ = "0.1.5"
|
__version__ = "0.1.7"
|
||||||
|
|||||||
+56
-6
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from monky_deployd import __version__
|
from monky_deployd import __version__
|
||||||
from monky_deployd import bundle as bundlemod
|
from monky_deployd import bundle as bundlemod
|
||||||
|
from monky_deployd import registry as registrymod
|
||||||
from monky_deployd import state as statemod
|
from monky_deployd import state as statemod
|
||||||
from monky_deployd.bao import BaoClient, BaoError, BaoToken, ManifestPathError, kv_data_path
|
from monky_deployd.bao import BaoClient, BaoError, BaoToken, ManifestPathError, kv_data_path
|
||||||
from monky_deployd.bundle import Bundle, BundleError
|
from monky_deployd.bundle import Bundle, BundleError
|
||||||
@@ -86,7 +87,7 @@ class Agent:
|
|||||||
def __init__(self, cfg: Config, *, prune: bool = False, docker: Docker | None = None):
|
def __init__(self, cfg: Config, *, prune: bool = False, docker: Docker | None = None):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.prune = prune
|
self.prune = prune
|
||||||
self.docker = docker or Docker(cfg.docker_bin)
|
self.docker = docker or Docker(cfg.docker_bin, docker_config=str(cfg.docker_config_dir))
|
||||||
self.transport = build(cfg)
|
self.transport = build(cfg)
|
||||||
self.tenancy_http = HttpClient(
|
self.tenancy_http = HttpClient(
|
||||||
self.transport, cfg.tenancy.scheme, cfg.tenancy.host, cfg.tenancy.port, timeout=cfg.tenancy.timeout_s
|
self.transport, cfg.tenancy.scheme, cfg.tenancy.host, cfg.tenancy.port, timeout=cfg.tenancy.timeout_s
|
||||||
@@ -274,13 +275,29 @@ class Agent:
|
|||||||
if hdr_sha and hdr_sha != b.sha:
|
if hdr_sha and hdr_sha != b.sha:
|
||||||
log.warning("bundle header sha %s disagrees with content %s", _short(hdr_sha), _short(b.sha))
|
log.warning("bundle header sha %s disagrees with content %s", _short(hdr_sha), _short(b.sha))
|
||||||
self._refusal_checks(b)
|
self._refusal_checks(b)
|
||||||
# secrets: lease -> login -> reads (values never logged; names only)
|
# secrets: lease -> login -> reads (values never logged; names only). An entry marked
|
||||||
|
# `use: registry-auth` is NOT an env var — it is the pull credential, written to the
|
||||||
|
# agent's own Docker config instead of `.env` (doc 24 §4a).
|
||||||
entries = b.manifest.get("entries", [])
|
entries = b.manifest.get("entries", [])
|
||||||
|
registry_entries = [e for e in entries if e.get("use") == registrymod.USE]
|
||||||
|
env_entries = [e for e in entries if e.get("use") != registrymod.USE]
|
||||||
if entries:
|
if entries:
|
||||||
token = self._lease_login("apply")
|
token = self._token_for_apply()
|
||||||
for e in entries:
|
for e in env_entries:
|
||||||
self._values[e["var"]] = self.bao.kv_read(token, e["path"], cfg.env_id, e.get("version"))
|
self._values[e["var"]] = self.bao.kv_read(token, e["path"], cfg.env_id, e.get("version"))
|
||||||
log.info("read %d secret(s): %s", len(entries), ", ".join(sorted(self._values)))
|
for e in registry_entries:
|
||||||
|
raw = self.bao.kv_read(token, e["path"], cfg.env_id, e.get("version"))
|
||||||
|
try:
|
||||||
|
auth = registrymod.parse(raw, default_registry=cfg.registry_host)
|
||||||
|
registrymod.write_docker_config(cfg.docker_config_dir, auth)
|
||||||
|
except registrymod.RegistryAuthError as exc:
|
||||||
|
log.warning("registry credential unusable (%s) — private images will not pull", exc)
|
||||||
|
log.info(
|
||||||
|
"read %d secret(s): %s%s",
|
||||||
|
len(entries),
|
||||||
|
", ".join(sorted(self._values)),
|
||||||
|
" (+ registry credential)" if registry_entries else "",
|
||||||
|
)
|
||||||
env_text = bundlemod.render_env(b.env_template, self._values)
|
env_text = bundlemod.render_env(b.env_template, self._values)
|
||||||
leftover = bundlemod.referenced_vars(env_text)
|
leftover = bundlemod.referenced_vars(env_text)
|
||||||
if leftover:
|
if leftover:
|
||||||
@@ -288,7 +305,17 @@ class Agent:
|
|||||||
release = self._promote(self._stage(b, env_text))
|
release = self._promote(self._stage(b, env_text))
|
||||||
compose = self._compose()
|
compose = self._compose()
|
||||||
log.info("compose pull")
|
log.info("compose pull")
|
||||||
compose.pull()
|
try:
|
||||||
|
compose.pull()
|
||||||
|
except ComposeError as exc:
|
||||||
|
# the registry's own message is the diagnosis ("no basic auth credentials", "manifest
|
||||||
|
# unknown", a DNS failure). Swallowing it behind `rc=1` cost an SSH hunt on env-dev-01.
|
||||||
|
detail = " | ".join(line.strip() for line in (exc.output or "").splitlines() if line.strip())[-600:]
|
||||||
|
self.state.last_result = "failed"
|
||||||
|
self.state.last_error = f"compose pull failed: {detail or exc}"
|
||||||
|
log.error("compose pull failed (rc=%s): %s", exc.rc, detail or "(no output)")
|
||||||
|
self._report("failed", b.sha, detail=f"compose pull: {detail or exc}"[:900])
|
||||||
|
return EX_FAIL
|
||||||
log.info("compose up -d --remove-orphans")
|
log.info("compose up -d --remove-orphans")
|
||||||
compose.up()
|
compose.up()
|
||||||
ok, containers = compose.wait_healthy(cfg.healthy_timeout_s)
|
ok, containers = compose.wait_healthy(cfg.healthy_timeout_s)
|
||||||
@@ -392,6 +419,29 @@ class Agent:
|
|||||||
if old and old != token:
|
if old and old != token:
|
||||||
self.bao.revoke_self(old)
|
self.bao.revoke_self(old)
|
||||||
|
|
||||||
|
def _token_for_apply(self) -> str:
|
||||||
|
"""The OpenBao token to read this bundle's secrets with. A lease-derived token that is
|
||||||
|
still valid is REUSED (a lease per apply burned tenancy's 5/h budget on every retry —
|
||||||
|
env-qa-02 pilot, 2026-09-07); the bootstrap token is swapped for a lease once; and if
|
||||||
|
tenancy rate-limits the lease while we hold a working token, apply with what we have
|
||||||
|
and swap later rather than fail the deploy."""
|
||||||
|
cfg = self.cfg
|
||||||
|
st = self.state.token
|
||||||
|
if self.token and st is not None and st.source == "lease":
|
||||||
|
try:
|
||||||
|
info = self.bao.lookup_self(self.token)
|
||||||
|
if int(info.get("ttl") or 0) > cfg.bao.renew_below_s:
|
||||||
|
return self.token
|
||||||
|
except BaoError as exc:
|
||||||
|
log.info("lease token no longer valid (%s); re-leasing", exc)
|
||||||
|
try:
|
||||||
|
return self._lease_login("apply")
|
||||||
|
except RateLimited as exc:
|
||||||
|
if self.token:
|
||||||
|
log.warning("lease rate-limited (%s); applying with the current token, swap deferred", exc)
|
||||||
|
return self.token
|
||||||
|
raise
|
||||||
|
|
||||||
def _lease_login(self, reason: str) -> str:
|
def _lease_login(self, reason: str) -> str:
|
||||||
assert self.tenancy is not None
|
assert self.tenancy is not None
|
||||||
lease = self.tenancy.lease(reason)
|
lease = self.tenancy.lease(reason)
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ class Container:
|
|||||||
|
|
||||||
|
|
||||||
class Docker:
|
class Docker:
|
||||||
def __init__(self, docker_bin: str = "docker", timeout_s: int = 600):
|
def __init__(self, docker_bin: str = "docker", timeout_s: int = 600, docker_config: str | None = None):
|
||||||
self.bin = docker_bin
|
self.bin = docker_bin
|
||||||
self.timeout_s = timeout_s
|
self.timeout_s = timeout_s
|
||||||
|
# doc 24 §4a: registry credentials live in a directory the AGENT owns, named explicitly
|
||||||
|
# rather than inherited from $HOME. The unit runs as `monky-deployd`, so a `docker login`
|
||||||
|
# by a human or by root is invisible here — which is exactly what cost env-dev-01 an hour.
|
||||||
|
self.docker_config = docker_config
|
||||||
|
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return shutil.which(self.bin) is not None
|
return shutil.which(self.bin) is not None
|
||||||
@@ -59,7 +63,7 @@ class Docker:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout or self.timeout_s,
|
timeout=timeout or self.timeout_s,
|
||||||
env={**os.environ, "COMPOSE_INTERACTIVE_NO_CLI": "1"},
|
env=self._env(),
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise ComposeError(what, 127, f"{self.bin} not found") from exc
|
raise ComposeError(what, 127, f"{self.bin} not found") from exc
|
||||||
@@ -70,6 +74,12 @@ class Docker:
|
|||||||
raise ComposeError(what, p.returncode, out.strip())
|
raise ComposeError(what, p.returncode, out.strip())
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _env(self) -> dict:
|
||||||
|
env = {**os.environ, "COMPOSE_INTERACTIVE_NO_CLI": "1"}
|
||||||
|
if self.docker_config:
|
||||||
|
env["DOCKER_CONFIG"] = self.docker_config
|
||||||
|
return env
|
||||||
|
|
||||||
# -- facts ------------------------------------------------------------------------------------
|
# -- facts ------------------------------------------------------------------------------------
|
||||||
def version(self) -> str | None:
|
def version(self) -> str | None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ class Config:
|
|||||||
healthy_timeout_s: int = 300
|
healthy_timeout_s: int = 300
|
||||||
compose_project: str = ""
|
compose_project: str = ""
|
||||||
docker_bin: str = "docker"
|
docker_bin: str = "docker"
|
||||||
|
# doc 24 §4a: the registry the bundle pulls from, used when the seeded credential is a bare
|
||||||
|
# `username:password` (a JSON credential names its own registry).
|
||||||
|
registry_host: str = "harbor.tikali.net"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
path: str = DEFAULT_CONFIG_PATH
|
path: str = DEFAULT_CONFIG_PATH
|
||||||
|
|
||||||
@@ -191,6 +194,13 @@ class Config:
|
|||||||
def lock_path(self) -> Path:
|
def lock_path(self) -> Path:
|
||||||
return Path(self.state_dir) / "lock"
|
return Path(self.state_dir) / "lock"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def docker_config_dir(self) -> Path:
|
||||||
|
"""Where the agent keeps its OWN registry credentials (`DOCKER_CONFIG`). Not `$HOME`: the
|
||||||
|
unit runs as `monky-deployd`, and a human's or root's `docker login` must not be what the
|
||||||
|
agent depends on (doc 24 §4a)."""
|
||||||
|
return Path(self.state_dir) / "docker"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_prod(self) -> bool:
|
def is_prod(self) -> bool:
|
||||||
if self.prod is not None:
|
if self.prod is not None:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""The registry credential (monky-design-docs doc 24 §4a).
|
||||||
|
|
||||||
|
The bundle's manifest carries one entry marked `use: registry-auth` — the estate-wide read-only
|
||||||
|
Harbor robot, copied by monky-tenancy into this environment's own prefix so the agent can read it
|
||||||
|
with the OpenBao policy it already has. It is NOT an env var (an entry in `.env` would put the
|
||||||
|
registry password into every container's environment), so it never reaches the compose file: it is
|
||||||
|
written to a Docker config **the agent owns**, and `DOCKER_CONFIG` points the docker CLI at it.
|
||||||
|
|
||||||
|
That last part is the whole point. The unit runs as `monky-deployd`, whose home is the state dir,
|
||||||
|
so a `docker login` performed by a human or by root is invisible to it — the failure looks exactly
|
||||||
|
like "no credentials at all" (env-dev-01, 2026-09-08).
|
||||||
|
|
||||||
|
Accepted shapes for the secret's value, because the seeded robot has been written both ways:
|
||||||
|
* a JSON object: `{"registry": …, "username": …, "password": …}`
|
||||||
|
* a `username:password` string, with the registry taken from `registry_host` config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger("monky-deployd.registry")
|
||||||
|
|
||||||
|
USE = "registry-auth"
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryAuthError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegistryAuth:
|
||||||
|
registry: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
def docker_config(self) -> dict:
|
||||||
|
token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
|
||||||
|
return {"auths": {self.registry: {"auth": token}}}
|
||||||
|
|
||||||
|
|
||||||
|
def parse(value: str, *, default_registry: str) -> RegistryAuth:
|
||||||
|
"""`value` is whatever the KV entry held; never logged, never echoed."""
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise RegistryAuthError("empty registry credential")
|
||||||
|
if text.startswith("{"):
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RegistryAuthError("registry credential is not valid JSON") from exc
|
||||||
|
user, pw = data.get("username"), data.get("password")
|
||||||
|
registry = data.get("registry") or default_registry
|
||||||
|
if not user or not pw:
|
||||||
|
raise RegistryAuthError("registry credential JSON needs username + password")
|
||||||
|
return RegistryAuth(registry=str(registry), username=str(user), password=str(pw))
|
||||||
|
if ":" not in text:
|
||||||
|
raise RegistryAuthError("registry credential is neither JSON nor username:password")
|
||||||
|
user, _, pw = text.partition(":")
|
||||||
|
if not default_registry:
|
||||||
|
raise RegistryAuthError("username:password credential needs a configured registry host")
|
||||||
|
return RegistryAuth(registry=default_registry, username=user, password=pw)
|
||||||
|
|
||||||
|
|
||||||
|
def write_docker_config(dir_path: Path, auth: RegistryAuth) -> Path:
|
||||||
|
"""0600 `config.json` in a directory the agent owns; DOCKER_CONFIG points the CLI at it."""
|
||||||
|
dir_path = Path(dir_path)
|
||||||
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
dir_path.chmod(0o700)
|
||||||
|
target = dir_path / "config.json"
|
||||||
|
tmp = dir_path / "config.json.tmp"
|
||||||
|
tmp.write_text(json.dumps(auth.docker_config(), indent=2) + "\n")
|
||||||
|
tmp.chmod(0o600)
|
||||||
|
tmp.replace(target)
|
||||||
|
log.info("registry credential in place for %s (%s)", auth.registry, auth.username)
|
||||||
|
return target
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
#
|
#
|
||||||
# T=<read-only GitLab deploy token, scope read_package_registry> # from the install kit / OpenBao
|
# T=<read-only GitLab deploy token, scope read_package_registry> # from the install kit / OpenBao
|
||||||
# curl -sSf -H "DEPLOY-TOKEN: $T" \
|
# curl -sSf -H "DEPLOY-TOKEN: $T" \
|
||||||
# https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.5/install.sh \
|
# https://scm.tikali.ai/api/v4/projects/69/packages/generic/monky-deployd/0.1.6/install.sh \
|
||||||
# | sudo bash -s -- --env env-qa-02 --site cbs --token "$T" --bootstrap-file bootstrap.jwt \
|
# | sudo bash -s -- --env env-qa-02 --site cbs --token "$T" --bootstrap-file bootstrap.jwt \
|
||||||
# [--transport sdk|proxy|system] [--version 0.1.5] [--enrol-jwt /path/monky-host.env-qa-02.jwt] \
|
# [--transport sdk|proxy|system] [--version 0.1.6] [--enrol-jwt /path/monky-host.env-qa-02.jwt] \
|
||||||
# [--laptop] [--source gitlab|gitea] [--docker-data-root /home/docker-data]
|
# [--laptop] [--source gitlab|gitea] [--docker-data-root /home/docker-data]
|
||||||
#
|
#
|
||||||
# --token / MONKY_DEPLOYD_TOKEN: the GitLab project is PRIVATE (its parent groups are private, so it
|
# --token / MONKY_DEPLOYD_TOKEN: the GitLab project is PRIVATE (its parent groups are private, so it
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
DEFAULT_VERSION="0.1.5"
|
DEFAULT_VERSION="0.1.6"
|
||||||
# Download source. PRIMARY is the GitLab project's generic package registry on scm.tikali.ai: inside
|
# Download source. PRIMARY is the GitLab project's generic package registry on scm.tikali.ai: inside
|
||||||
# the estate gitea.cbs.tikali.net is split-horizon to jump1's RED EIP (10.10.0.175), which has no HTTP
|
# the estate gitea.cbs.tikali.net is split-horizon to jump1's RED EIP (10.10.0.175), which has no HTTP
|
||||||
# ingress, so backend boxes cannot reach the Gitea mirror (cbs/iac#102); scm.tikali.ai they can. The
|
# ingress, so backend boxes cannot reach the Gitea mirror (cbs/iac#102); scm.tikali.ai they can. The
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "monky-deployd"
|
name = "monky-deployd"
|
||||||
version = "0.1.5"
|
version = "0.1.7"
|
||||||
description = "Monky backend pull agent: checkin -> bundle -> lease -> OpenBao -> docker compose -> report, over the ziti mesh (MONKY-ADR-0028)"
|
description = "Monky backend pull agent: checkin -> bundle -> lease -> OpenBao -> docker compose -> report, over the ziti mesh (MONKY-ADR-0028)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+28
-5
@@ -107,7 +107,7 @@ def test_rollback_refused_unless_allowed(bootstrapped, tenancy, fake_docker):
|
|||||||
tenancy.set_files(first)
|
tenancy.set_files(first)
|
||||||
assert tick(cfg) == EX_FAIL
|
assert tick(cfg) == EX_FAIL
|
||||||
assert tenancy.reports[-1]["result"] == "failed" and "ROLLBACK_REFUSED" in tenancy.reports[-1]["detail"]
|
assert tenancy.reports[-1]["result"] == "failed" and "ROLLBACK_REFUSED" in tenancy.reports[-1]["detail"]
|
||||||
assert len(tenancy.leases) == 2 # a refused bundle never leases
|
assert len(tenancy.leases) == 1 # a refused bundle never leases; the 2nd apply reused the lease token
|
||||||
tenancy.set_files(make_files(meta={"agent": {"allow_rollback": True}}))
|
tenancy.set_files(make_files(meta={"agent": {"allow_rollback": True}}))
|
||||||
assert tick(cfg) == EX_OK
|
assert tick(cfg) == EX_OK
|
||||||
|
|
||||||
@@ -246,10 +246,9 @@ def test_legacy_approle_lease_is_refused_loudly(bootstrapped, tenancy, fake_dock
|
|||||||
assert "compose up" not in fake_docker.subcommands()
|
assert "compose up" not in fake_docker.subcommands()
|
||||||
|
|
||||||
|
|
||||||
def test_lease_rate_limited_is_temporary(bootstrapped, tenancy, fake_docker):
|
# (test_lease_rate_limited_is_temporary was retired in 0.1.6: a rate-limited lease is only a
|
||||||
cfg = bootstrapped
|
# temporary failure when NO working token exists; with one, the agent applies and defers the swap —
|
||||||
tenancy.lease_limit = 0
|
# see test_rate_limited_lease_does_not_block_an_apply_when_a_token_exists.)
|
||||||
assert tick(cfg) == EX_TEMPFAIL
|
|
||||||
|
|
||||||
|
|
||||||
def test_unhealthy_after_up_reports_failed_with_compose_logs(bootstrapped, tenancy, fake_docker):
|
def test_unhealthy_after_up_reports_failed_with_compose_logs(bootstrapped, tenancy, fake_docker):
|
||||||
@@ -320,3 +319,27 @@ def test_write_private_mode(tmp_path):
|
|||||||
p = tmp_path / "d" / "f"
|
p = tmp_path / "d" / "f"
|
||||||
statemod.write_private(p, b"x")
|
statemod.write_private(p, b"x")
|
||||||
assert oct(p.stat().st_mode & 0o777) == "0o600" and not any(n.startswith(".f.") for n in os.listdir(p.parent))
|
assert oct(p.stat().st_mode & 0o777) == "0o600" and not any(n.startswith(".f.") for n in os.listdir(p.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_apply_reuses_the_lease_token(bootstrapped, tenancy, bao, fake_docker):
|
||||||
|
"""A valid lease-derived token is reused: a new bundle does NOT lease again (5/h budget —
|
||||||
|
env-qa-02 pilot: a retried deploy re-leased every 60 s and hit LEASE_RATE_LIMITED forever)."""
|
||||||
|
cfg = bootstrapped
|
||||||
|
assert tick(cfg) == EX_OK
|
||||||
|
assert len(tenancy.leases) == 1
|
||||||
|
tenancy.set_files(make_files(manifest=make_manifest(versions={"gemini_api_key": 1})))
|
||||||
|
assert tick(cfg) == EX_OK
|
||||||
|
assert len(tenancy.leases) == 1 # reused
|
||||||
|
st = statemod.load(cfg.state_path, ENV)
|
||||||
|
assert st.applied_sha == tenancy.desired_sha and st.token.source == "lease"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limited_lease_does_not_block_an_apply_when_a_token_exists(bootstrapped, tenancy, bao, fake_docker):
|
||||||
|
"""tenancy 429 on lease while the bootstrap token still works → apply with it, swap deferred."""
|
||||||
|
cfg = bootstrapped
|
||||||
|
tenancy.lease_limit = 0
|
||||||
|
assert tick(cfg) == EX_OK
|
||||||
|
assert tenancy.leases == []
|
||||||
|
st = statemod.load(cfg.state_path, ENV)
|
||||||
|
assert st.applied_sha == tenancy.desired_sha and st.token.source == "bootstrap"
|
||||||
|
assert tenancy.reports[-1]["result"] == "applied"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""The registry credential (doc 24 §4a): parsed from either seeded shape, written to a Docker
|
||||||
|
config the AGENT owns, never an env var, never logged."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from monky_deployd import registry as registrymod
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_json_and_user_colon_password():
|
||||||
|
a = registrymod.parse(
|
||||||
|
json.dumps({"registry": "harbor.tikali.net", "username": "robot$pull", "password": "p4ss"}),
|
||||||
|
default_registry="ignored.example",
|
||||||
|
)
|
||||||
|
assert (a.registry, a.username, a.password) == ("harbor.tikali.net", "robot$pull", "p4ss")
|
||||||
|
b = registrymod.parse("robot$pull:p4ss", default_registry="harbor.tikali.net")
|
||||||
|
assert (b.registry, b.username, b.password) == ("harbor.tikali.net", "robot$pull", "p4ss")
|
||||||
|
# a password containing a colon survives (partition on the FIRST one)
|
||||||
|
c = registrymod.parse("robot$pull:p4:ss", default_registry="h")
|
||||||
|
assert c.password == "p4:ss"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", " ", "no-colon-here", '{"username": "u"}', "{not json"])
|
||||||
|
def test_refuses_what_it_cannot_use(bad):
|
||||||
|
with pytest.raises(registrymod.RegistryAuthError):
|
||||||
|
registrymod.parse(bad, default_registry="harbor.tikali.net")
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_config_is_written_0600_in_a_directory_the_agent_owns(tmp_path):
|
||||||
|
auth = registrymod.parse("robot$pull:p4ss", default_registry="harbor.tikali.net")
|
||||||
|
target = registrymod.write_docker_config(tmp_path / "docker", auth)
|
||||||
|
assert target.exists()
|
||||||
|
assert oct(target.stat().st_mode)[-3:] == "600"
|
||||||
|
assert oct(target.parent.stat().st_mode)[-3:] == "700"
|
||||||
|
cfg = json.loads(target.read_text())
|
||||||
|
token = cfg["auths"]["harbor.tikali.net"]["auth"]
|
||||||
|
assert base64.b64decode(token).decode() == "robot$pull:p4ss"
|
||||||
|
# rewriting is idempotent (the agent does it every tick)
|
||||||
|
registrymod.write_docker_config(tmp_path / "docker", auth)
|
||||||
|
assert json.loads(target.read_text()) == cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_runner_points_the_cli_at_that_directory():
|
||||||
|
from monky_deployd.compose import Docker
|
||||||
|
|
||||||
|
d = Docker("docker", docker_config="/var/lib/monky-deployd/docker")
|
||||||
|
assert d._env()["DOCKER_CONFIG"] == "/var/lib/monky-deployd/docker"
|
||||||
|
# unset when no directory is configured — never silently fall back to a human's $HOME
|
||||||
|
assert "DOCKER_CONFIG" not in Docker("docker")._env()
|
||||||
Reference in New Issue
Block a user