diff --git a/stacks/muninn-gate/Dockerfile b/stacks/muninn-gate/Dockerfile new file mode 100644 index 0000000..ddc738a --- /dev/null +++ b/stacks/muninn-gate/Dockerfile @@ -0,0 +1,27 @@ +# muninn-gate — WG-internal HTTP front door for the Muninn ingestion queue (#377). +# No Dockerfile in the upstream repo (vh/muninn-gate); this is infra-ops's. +# muninn-dispatch==0.1.4 comes from the internal Gitea PyPI index "gitea"; +# the read token is passed as a BuildKit secret (never baked into a layer). +# +# DOCKER_BUILDKIT=1 docker build \ +# --secret id=gitea_pw,src= \ +# -t muninn-gate:0.0.14 . +FROM python:3.11-slim + +RUN pip install --no-cache-dir uv==0.11.* \ + && useradd --system --uid 10014 --create-home --home-dir /home/gate gate + +WORKDIR /app +COPY pyproject.toml README.md uv.lock ./ +COPY src ./src + +# Install the app + deps. muninn-dispatch resolves only from the "gitea" index +# (pinned via [tool.uv.sources]); everything else from PyPI. +RUN --mount=type=secret,id=gitea_pw \ + UV_INDEX_GITEA_USERNAME=vh \ + UV_INDEX_GITEA_PASSWORD="$(cat /run/secrets/gitea_pw)" \ + uv pip install --system --no-cache . + +# The launcher (compose) owns the bind — no CMD here. The app carries `bind` +# for observability only; --host/--port passed by compose are authoritative. +USER gate diff --git a/stacks/muninn-gate/README.md b/stacks/muninn-gate/README.md new file mode 100644 index 0000000..92296c4 --- /dev/null +++ b/stacks/muninn-gate/README.md @@ -0,0 +1,56 @@ +# muninn-gate + +WG-internal HTTP front door for the Muninn ingestion queue (Worldtree #377). +Path-addressed submit, list/status, cancel/retry, watcher-liveness health. +Upstream: `vh/muninn-gate` (no Dockerfile there — this stack owns containerization). + +## Where it runs + +**corviduo-dev (10.250.50.152)**, co-located with the worldtree-personal muninn +watcher (`worldtree-personal-worldtree-muninn-1`). infra-ops-managed stack, +separate from the worldtree CI/CD compose. Bind: `10.250.50.152:8090`, `network_mode: host`. + +## Key wiring (why it's shaped this way) + +- **`ingestion_root: /data/state/ingestion`** — the `worldtree-personal_worldtree-state` + volume, mounted at `/data/state`. Byte-identical path to the watcher's view; the + dispatch records absolute paths and both processes read each other's. + Acceptance: `/health` → `watcher.running: true` **proves** this byte-identity + (gate is reading the heartbeat the watcher writes). `no_heartbeat` with the + watcher up = root mismatch. +- **`user: "1000:1000"`** — the ingestion dir is `vh:vh 0755`; the gate must run as + uid 1000 to write the queue (non-root, least-privilege). Overrides the image user. +- **staging** — `/mnt/muninn-staging/mimir-inbox`, same absolute path bound `:ro` in + BOTH the gate and the watcher (worldtree-dev's b162). Currently a LOCAL placeholder + dir; becomes the shared mount when mimir-inbox (the writer) lands. **No gate-side + check can tell a real share from an empty dir** (docker fabricates a missing bind + source) — the guard is operational: confirm the host mount exists before wiring/ + repointing a bind. Repoint needs a restart (config is read once at boot). +- **health probe uses `/ping`, not `/health`** — `/health` is always-200 by design + (watcher-down is report *content*, not a status code), so it must never be the + liveness probe or it would restart the wrong container. + +## Config (single-writer, infra-ops) + +Real config with bearer-key secrets lives on the server at +`/opt/docker/conf/muninn-gate/muninn-gate.yaml` (`1000:1000`, `0600`, gitignored). +Redacted schema: `conf/muninn-gate.example.yaml`. Schema is CLOSED — any unknown +field is a boot failure. Keys are flat-scope (`read | submit | control`, no +inheritance); `name` is the non-secret `submitted_by` identity. + +## Build + deploy + +Image builds out-of-band — the Gitea read token (for `muninn-dispatch==0.1.4` from +the internal index) rides as a BuildKit secret, never a layer: + +```bash +DOCKER_BUILDKIT=1 docker build --secret id=gitea_pw,src= -t muninn-gate:0.0.14 . +docker compose up -d # after installing the config to /opt/docker/conf/muninn-gate/ +``` + +## Status + +Booted + healthy 2026-07-31; `/ping` + `/health` (watcher:running:true, +ingestion_root_writable:true) verified. **SUBMIT deferred** — returns `not_found` +against the placeholder staging until the real shared mount + mimir-inbox writer +exist; then repoint + the one-file path-agreement probe + the real acceptance run. diff --git a/stacks/muninn-gate/compose.yaml b/stacks/muninn-gate/compose.yaml new file mode 100644 index 0000000..05ccb66 --- /dev/null +++ b/stacks/muninn-gate/compose.yaml @@ -0,0 +1,70 @@ +# muninn-gate — WG-internal HTTP front door for the Muninn ingestion queue (#377). +# Deployed on corviduo-dev (10.250.50.152), co-located with the worldtree-personal +# muninn watcher (worldtree-personal-worldtree-muninn-1). infra-ops-managed stack, +# separate from the worldtree CI/CD compose. +# +# Image is built out-of-band (the Gitea read token rides as a BuildKit secret, so +# it never lands in a layer) — see README.md: +# DOCKER_BUILDKIT=1 docker build --secret id=gitea_pw,src= -t muninn-gate:0.0.14 . +# +# Real config (with bearer-key secrets) lives on the server at +# /opt/docker/conf/muninn-gate/muninn-gate.yaml (gitignored); repo carries the +# redacted conf/muninn-gate.example.yaml. +services: + muninn-gate: + image: muninn-gate:0.0.14 + container_name: muninn-gate + restart: unless-stopped + # Run as the ingestion-owner uid (vh, 1000:1000) so the gate can WRITE the + # queue (ingestion dir is 1000:1000 0755). Overrides the image's build user; + # non-root, least-privilege (no root needed — binds :8090, reads config + + # staging, writes only ingestion). The mounted config is chowned 1000:1000 0600. + user: "1000:1000" + # WG-internal bind straight to the host WG address; the launcher owns the bind + # (the app's `bind` field is observability-only). Host networking so --host/--port + # land directly on corviduo-dev. + network_mode: host + command: + - uvicorn + - muninn_gate.entrypoint:app_factory + - --factory + - --host + - "10.250.50.152" + - --port + - "8090" + environment: + MUNINN_GATE_CONFIG: /app/config/muninn-gate.yaml + volumes: + # ingestion_root = /data/state/ingestion — the SAME volume the personal muninn + # watcher uses, byte-identical path in both containers (absolute-path agreement). + # NOTE (hardening candidate): whole state volume per muninn-dev's spec; a subpath + # mount of just `ingestion` -> /data/state/ingestion would be tighter (gate only + # needs RW on ingestion). Flag to muninn-dev before adopting. + - worldtree-personal_worldtree-state:/data/state + # single-writer mounted config (bearer-key secrets), read-only: + - /opt/docker/conf/muninn-gate/muninn-gate.yaml:/app/config/muninn-gate.yaml:ro + # shared staging root — SAME absolute path as the watcher's bind, read-only + # (gate only resolves + reads staging; never writes there): + - /mnt/muninn-staging/mimir-inbox:/mnt/muninn-staging/mimir-inbox:ro + healthcheck: + # /ping is the one anonymous route; returns {"service":"ok"}. NB /health must + # NOT be the liveness probe — it is always-200 by design even when the watcher + # is down, so it would never restart the gate (it would wrongly signal the + # watcher's container). + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if b'ok' in urllib.request.urlopen('http://10.250.50.152:8090/ping', timeout=3).read() else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - homepage.group=Worldtree + - homepage.name=Muninn Gate + - homepage.icon=mdi-gate-arrow-right + - homepage.description=Muninn ingestion queue front door (#377) + - homepage.href=http://10.250.50.152:8090/ping + +volumes: + # The worldtree-personal muninn watcher's state volume — external, managed by the + # worldtree-personal compose. We attach to it read/write for the ingestion queue. + worldtree-personal_worldtree-state: + external: true diff --git a/stacks/muninn-gate/conf/muninn-gate.example.yaml b/stacks/muninn-gate/conf/muninn-gate.example.yaml new file mode 100644 index 0000000..641e887 --- /dev/null +++ b/stacks/muninn-gate/conf/muninn-gate.example.yaml @@ -0,0 +1,32 @@ +# muninn-gate mounted config — REDACTED EXAMPLE (committed). +# The real file (with bearer-key secrets) lives on corviduo-dev at +# /opt/docker/conf/muninn-gate/muninn-gate.yaml and is gitignored. +# +# Schema is CLOSED: an unknown field (top-level or per-key) is a BOOT FAILURE, +# not a warning. Read once at boot; every validation failure aborts before the +# socket binds. Four top-level fields only. + +# Absolute, existing directory, same volume as the personal muninn watcher. +# Byte-identical to the watcher's view: /data/state/ingestion in both containers. +ingestion_root: /data/state/ingestion + +# Submit-path allowlist (non-empty; each absolute, existing, a directory). +# Canonicalized at boot. Ratified 2026-07-30. Currently a LOCAL placeholder dir +# on corviduo-dev; becomes the shared mount when mimir-inbox (the writer) lands. +staging_roots: + - /mnt/muninn-staging/mimir-inbox + +# Bearer credentials. `name` = non-secret caller identity, recorded as +# `submitted_by`. Flat scopes (no hierarchy/inheritance): read | submit | control. +# Names AND key values must both be unique; a padded key fails boot. +keys: + - name: mimir-inbox + key: <64-hex-secret, provisioned by infra-ops> + scopes: [read, submit] + - name: ops-curl + key: + scopes: [read, submit, control] + +# WG-internal address — observability only, NOT read by the app. Keep consistent +# with the launcher's --host/--port (the launcher is authoritative). +bind: 10.250.50.152:8090