From 55e2e836a8c1afcf9cc43d2347ca9388b5090f74 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 18 May 2026 22:01:29 -0700 Subject: [PATCH] skaldsong: scaffold compose stack + deploy playbook for ana-docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry-pull pattern matching Worldtree: CI on vh/skaldsong builds and pushes gitea.phasefinal.com/vh/skaldsong:, this playbook pulls + recreates. SHA-pin only per current preference; no :latest moving-tag advance yet (revisit once /health exercises Worldtree + Kokoro reach). Host port 8300 (host) → 8000 (container). Persistent state under /opt/docker/conf/skaldsong/{db,runs}. Bifrost endpoint URL 10.250.50.70:8300 will need a paired BIFROST_CLIENT_ALLOWED_HOSTS update on corviduo-dev Worldtree at first deploy. --- playbooks/deploy-skaldsong.yaml | 117 ++++++++++++++++++++++++++++++ stacks/skaldsong/.env.example | 53 ++++++++++++++ stacks/skaldsong/README.md | 122 ++++++++++++++++++++++++++++++++ stacks/skaldsong/compose.yaml | 92 ++++++++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 playbooks/deploy-skaldsong.yaml create mode 100644 stacks/skaldsong/.env.example create mode 100644 stacks/skaldsong/README.md create mode 100644 stacks/skaldsong/compose.yaml diff --git a/playbooks/deploy-skaldsong.yaml b/playbooks/deploy-skaldsong.yaml new file mode 100644 index 0000000..9094e82 --- /dev/null +++ b/playbooks/deploy-skaldsong.yaml @@ -0,0 +1,117 @@ +# Deploy skaldsong (https://gitea.phasefinal.com/vh/skaldsong) to a +# Docker host following the PFI /opt/docker/ convention (ana-docker by +# default, but the playbook works against any host with Docker in place). +# +# Registry-pull pattern, NOT build-on-host: vh/skaldsong's CI builds and +# pushes `gitea.phasefinal.com/vh/skaldsong:` (+ :latest) to the +# gitea registry; this playbook just pulls + recreates the container. +# Differs from althing-chamber / asset-engine playbooks which build the +# image on-host from a git clone — those are local-build stacks. +# +# Idempotent: rerunning is safe. Creates-gates skip work that's already +# done; `docker compose up -d` is itself idempotent (no restart unless +# compose content, env, or image SHA changed). +# +# Usage: +# # CI passes the triggering commit SHA via --var ref=${{ github.sha }}: +# scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \ +# --var ref= +# +# # Manual runs can pass a branch or tag (will resolve to the registry +# # tag of that name — CI publishes :main and :v0.1.0 tags alongside +# # the SHA-pin): +# scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \ +# --var ref=main +# +# Prereqs on the target host: +# - Docker + docker compose plugin +# - Target user (lkraven) is in the `docker` group +# - `docker login gitea.phasefinal.com` has been done at least once +# (credentials persist in /home/lkraven/.docker/config.json). CI's +# workflow runs its own `docker login` step from REGISTRY_USER / +# REGISTRY_TOKEN secrets; manual runs assume the host's auth is +# already established. + +vars: + registry_image: gitea.phasefinal.com/vh/skaldsong + ref: latest + compose_dir: /opt/docker/compose/skaldsong + db_dir: /opt/docker/conf/skaldsong/db + runs_dir: /opt/docker/conf/skaldsong/runs + host_port: "8300" + +steps: + # ── compose + state dirs ──────────────────────────────────────────── + - name: Ensure compose dir exists + shell: mkdir -p {{ compose_dir }} + creates: "{{ compose_dir }}" + + - name: Ensure DB dir exists + # Created as lkraven (uid 1000 on these hosts), matching the + # container's app user — no chown dance needed. + shell: mkdir -p {{ db_dir }} + creates: "{{ db_dir }}" + + - name: Ensure runs dir exists + # Separate from db_dir so runs/ can later move to a bigger volume + # without touching DB state. + shell: mkdir -p {{ runs_dir }} + creates: "{{ runs_dir }}" + + # ── deploy compose files ──────────────────────────────────────────── + - name: Upload compose.yaml + upload: + src: stacks/skaldsong/compose.yaml + dest: "{{ compose_dir }}/compose.yaml" + mode: "0644" + + - name: Seed .env from template (only if absent) + upload: + src: stacks/skaldsong/.env.example + dest: "{{ compose_dir }}/.env" + mode: "0644" + when: "[ ! -f {{ compose_dir }}/.env ]" + + # ── pin SKALDSONG_IMAGE to the requested ref ──────────────────────── + - name: Set SKALDSONG_IMAGE pin to {{ ref }} + # Idempotent: strip any existing SKALDSONG_IMAGE= line and append + # the new one. CI passes --var ref=; manual runs pass branch / + # tag names (CI publishes those as registry tags too). + shell: | + sed -i '/^SKALDSONG_IMAGE=/d' {{ compose_dir }}/.env + echo "SKALDSONG_IMAGE={{ registry_image }}:{{ ref }}" >> {{ compose_dir }}/.env + + # ── pull + bring up ───────────────────────────────────────────────── + - name: Pull image {{ registry_image }}:{{ ref }} + # Explicit pull step (rather than relying on compose up's pull) so + # any auth failure surfaces here, not buried in compose output. + shell: docker pull {{ registry_image }}:{{ ref }} + + - name: docker compose up -d --force-recreate + # --force-recreate picks up env changes even if the image SHA is + # identical to what's running (rare but happens on env-only deploys). + # --pull never: we just pulled explicitly above, so no need for + # compose to re-check. + shell: cd {{ compose_dir }} && docker compose up -d --force-recreate --pull never + + - name: Wait for skaldsong /health to respond + # Container's healthcheck is internal; this host-side poll confirms + # the published port is reachable + the FastAPI app finished booting. + # Generous retry budget — first-deploy bootstrapping (DB migrations, + # SPA asset indexing) can take 30s+. + shell: | + for i in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:{{ host_port }}/health && exit 0 + sleep 2 + done + exit 1 + changed_when: "false" + +verify: + - name: skaldsong /health returns 200 + shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/health + changed_when: "false" + + - name: skaldsong container running + healthy + shell: docker ps --filter name=^/skaldsong$ --format '{{.Status}}' | grep -q 'healthy' + changed_when: "false" diff --git a/stacks/skaldsong/.env.example b/stacks/skaldsong/.env.example new file mode 100644 index 0000000..e94dfbe --- /dev/null +++ b/stacks/skaldsong/.env.example @@ -0,0 +1,53 @@ +# skaldsong stack tunables. Copy to `.env` on ana-docker before deploying. +# +# The deploy playbook seeds `.env` from this template on first run only — +# it won't clobber an existing `.env`. + +# Image tag. CI's deploy.yaml pushes this SHA-pinned per commit: +# gitea.phasefinal.com/vh/skaldsong: +# Workflow passes the SHA via `scripts/elway ... --var ref=`; the +# playbook substitutes that into this var before `docker compose up -d`. +# For manual runs, set to a known good SHA (e.g. after a green CI run): +SKALDSONG_IMAGE=gitea.phasefinal.com/vh/skaldsong:latest + +# Host port exposing the wizard UI + Bifrost endpoint. Container always +# listens on 8000 internally. 8300 is the canonical slot — adjacent to +# the 8xxx web-app block (asset-engine:8200, sillytavern:8100, beszel:8090). +SKALDSONG_PORT=8300 + +# Bind address for the host port. 0.0.0.0 = LAN-reachable. +SKALDSONG_BIND=0.0.0.0 + +# Host paths for persistent state. db/ holds skaldsong-ui.db (SQLite, +# wizard state + story rows); runs/ holds per-story generation pipeline +# artifacts (spec, state, output, logs, manuscripts). Created with uid +# 1000 (matches container's app user — no chown dance needed). +SKALDSONG_DB_DIR=/opt/docker/conf/skaldsong/db +SKALDSONG_RUNS_DIR_HOST=/opt/docker/conf/skaldsong/runs + +# Worldtree integration ───────────────────────────────────────────────── + +# Shared-secret Bearer for Worldtree's HS256 Bifrost JWT path. Same +# value as Worldtree's WORLDTREE_SKALDSONG_USER_KEY env. Get from +# worldtree-dev when standing up a fresh deploy; rotate via worldtree-dev's +# /heimdall:rotate flow if compromise suspected. +SKALDSONG_BIFROST_JWT_KEY= + +# URL Worldtree uses to call BACK to skaldsong's Bifrost endpoint at +# tool-dispatch time. Must match Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS +# entry. From ana-docker LAN: http://10.250.50.70:8300 (this port). +# Coordinated update with worldtree-dev required if host:port changes. +SKALDSONG_HOST_BIFROST_ENDPOINT_URL=http://10.250.50.70:8300 + +# URL skaldsong uses to call Worldtree's conversation API. From +# ana-docker, corviduo-dev's IP:port LAN-direct. +SKALDSONG_WORLDTREE_API_URL=http://10.250.50.152:8080 + +# CORS — comma-separated origins. Include the SPA's public hostname AND +# any dev origins still in rotation. +SKALDSONG_HOST_CORS_ORIGINS=http://10.250.50.70:8300 + +# TTS — Kokoro on irv-ml1, reached over WireGuard via ana-wg. Same URL +# works fleet-wide; the WG tunnel handles the cross-site routing. +SKALDSONG_TTS_ENGINE=kokoro +SKALDSONG_TTS_BASE_URL=http://10.100.79.3:8193 diff --git a/stacks/skaldsong/README.md b/stacks/skaldsong/README.md new file mode 100644 index 0000000..78ad74c --- /dev/null +++ b/stacks/skaldsong/README.md @@ -0,0 +1,122 @@ +# skaldsong + +Wizard + reader surface for multi-agent storytelling. Single FastAPI +process serves both the SvelteKit-built SPA (static, baked into the +image) and the wizard backend. Talks to Worldtree as a Bifrost +consumer — Worldtree issues per-dispatch JWTs (post-v0.3) that +skaldsong's `/bifrost` endpoint verifies against the shared HS256 +secret. + +**Server:** ana-docker +**URL:** `http://10.250.50.70:8300` (configurable via `.env`) +**Upstream repo:** [vh/skaldsong](https://gitea.phasefinal.com/vh/skaldsong) +**Image:** `gitea.phasefinal.com/vh/skaldsong:` — built + pushed +by vh/skaldsong's CI on every push to `main` and on tag. + +## Path layout (on ana-docker) + +| Host path | Container path | Purpose | Restic? | +|---|---|---|---| +| `/opt/docker/compose/skaldsong/` | — | compose.yaml + .env | included (via `/opt/docker`) | +| `/opt/docker/conf/skaldsong/db/` | `/app/state/db` | SQLite (`skaldsong-ui.db` + WAL/SHM) — wizard state, story rows | **included** | +| `/opt/docker/conf/skaldsong/runs/` | `/app/state/runs` | Per-story generation pipeline artifacts (spec, state, output, logs, manuscripts) | **included** | + +## Network model + +Internal tooling, LAN-only. Container's :8000 published on host +:8300 (configurable via `SKALDSONG_BIND` / `SKALDSONG_PORT`); access +direct at `http://10.250.50.70:8300`. No Traefik, no TLS terminator, +no public hostname. + +Skaldsong talks to two services on the LAN: + +- **Worldtree** at `http://10.250.50.152:8080` (corviduo-dev, + LAN-direct from ana-docker — same subnet). +- **Kokoro TTS** at `http://10.100.79.3:8193` (irv-ml1, reached via + the WireGuard tunnel on ana-wg — same path asset-engine uses). + +Worldtree calls back to skaldsong's `/bifrost` endpoint at +`http://10.250.50.70:8300` for tool-dispatch — that URL must be in +Worldtree's `BIFROST_CLIENT_ALLOWED_HOSTS`. Host:port changes +require a coordinated allowlist update on Worldtree-side (see +[corviduo-dev README](../../servers/corviduo-dev/README.md) for +the docker-as-root edit pattern). + +## Deploy + +Two paths — automated (preferred) and manual (escape hatch). + +### Automated (Gitea Actions, push-to-main) + +vh/skaldsong ships `.gitea/workflows/deploy.yaml`. Push to main + +manual `workflow_dispatch` triggers: + +1. Build + push `gitea.phasefinal.com/vh/skaldsong:${{ github.sha }}` + and `:latest` to the gitea registry. +2. Checkout this management repo using `MGMT_REPO_TOKEN`. +3. Run `scripts/elway ana-docker --playbook + playbooks/deploy-skaldsong.yaml --var ref=${{ github.sha }}` via + `DEPLOY_SSH_KEY`. + +Three secrets required on vh/skaldsong's Actions settings: +- `DEPLOY_SSH_KEY` — private key authorized on ana-docker for lkraven. +- `MGMT_REPO_TOKEN` — Gitea PAT with `read:repository` on this repo. +- `REGISTRY_USER` + `REGISTRY_TOKEN` — for the docker login step + (token needs `write:package` scope). + +### Manual (elway from a workstation) + +```bash +# Deploy a specific SHA (CI just pushed it): +scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \ + --var ref= + +# Or pin to a release tag: +scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \ + --var ref=v0.1.0 +``` + +## Env-var contract + +| Var | In compose? | In `.env`? | Notes | +|---|---|---|---| +| `SKALDSONG_IMAGE` | ✓ (image:) | ✓ | Full image URI; CI passes SHA-pin via elway `--var ref=` → playbook → this var. | +| `SKALDSONG_PORT` | ✓ | ✓ | Host-side port (8300 default). Internal always 8000. | +| `SKALDSONG_BIND` | ✓ | ✓ | Host bind address (0.0.0.0 default). | +| `SKALDSONG_DB_DIR` | ✓ (mount) | ✓ | Host path for SQLite. | +| `SKALDSONG_RUNS_DIR_HOST` | ✓ (mount) | ✓ | Host path for generation artifacts. | +| `SKALDSONG_BIFROST_JWT_KEY` | ✓ (anchor) | ✓ | Shared HS256 secret w/ Worldtree's `WORLDTREE_SKALDSONG_USER_KEY`. | +| `SKALDSONG_HOST_BIFROST_ENDPOINT_URL` | ✓ (anchor) | ✓ | URL Worldtree calls back to (must match allowlist). | +| `SKALDSONG_WORLDTREE_API_URL` | ✓ (anchor) | ✓ | URL skaldsong calls Worldtree at. | +| `SKALDSONG_HOST_CORS_ORIGINS` | ✓ (anchor) | ✓ | Comma-separated CORS origins. | +| `SKALDSONG_TTS_ENGINE` | ✓ (anchor) | ✓ | `kokoro` (only engine in v1). | +| `SKALDSONG_TTS_BASE_URL` | ✓ (anchor) | ✓ | Kokoro URL (WG-routed). | + +`&skaldsong-env` anchor declares all `SKALDSONG_*` vars via `${VAR:-}` +substitution. Same env-anchor pattern as Worldtree's `&worldtree-env` +(see [Worldtree #175 heuristic](https://gitea.phasefinal.com/vh/worldtree/issues/175)); +new var additions must land in BOTH the `.env` AND the anchor. + +## Restart semantics + +Per skaldsong's `INV-006` (host CLAUDE.md): process restart marks any +`running` generation rows `partial` and never auto-resumes. Deploy +mid-run is safe — the SPA's reader treats partials like a power +outage. Cancellation of in-flight runs on deploy is acceptable for +v1. + +`docker compose up -d --force-recreate --pull always` is the +playbook's apply step (or `--pull never` when called with a specific +`--var ref=` where the image was pre-pulled). Same kill-signal + +graceful-shutdown pattern as Worldtree. + +## Worldtree allowlist coordination + +The `SKALDSONG_HOST_BIFROST_ENDPOINT_URL` value MUST be in Worldtree's +`BIFROST_CLIENT_ALLOWED_HOSTS` env var on corviduo-dev. As of +deploy-time the value is `10.250.50.70:8300`. + +If you change `SKALDSONG_PORT` or move skaldsong off ana-docker, the +allowlist needs a paired update on corviduo-dev. Reference: the +edit-via-docker-as-root pattern in +[servers/corviduo-dev/README.md](../../servers/corviduo-dev/README.md#permissions-model--the-docker-as-root-pattern). diff --git a/stacks/skaldsong/compose.yaml b/stacks/skaldsong/compose.yaml new file mode 100644 index 0000000..62e07d1 --- /dev/null +++ b/stacks/skaldsong/compose.yaml @@ -0,0 +1,92 @@ +# skaldsong stack — single FastAPI + SPA container. +# +# Skaldsong is the wizard + reader surface that talks to Worldtree as a +# Bifrost consumer. One process serves both the SvelteKit-style SPA +# (static, baked into the image) and the wizard backend (FastAPI), +# plus the Bifrost endpoint Worldtree calls back to. +# +# Image is built + pushed by vh/skaldsong's `.gitea/workflows/deploy.yaml` +# to gitea.phasefinal.com/vh/skaldsong, then pulled here. Workflow +# passes the triggering commit SHA via `--var ref=...` to elway, which +# substitutes it into ${SKALDSONG_IMAGE} for this compose. SHA-pinned +# only by current preference — no :latest moving-tag advance (re-evaluate +# once the /health endpoint exercises Worldtree + Kokoro reachability). +# +# Internal tooling — accessed directly on host:port over the LAN, does +# NOT traverse Traefik. State persists under /opt/docker/conf/skaldsong/ +# {db,runs} on the host; db/ holds skaldsong-ui.db (SQLite, wizard state +# + story rows), runs/ holds per-story generation pipeline artifacts +# (spec, state, output, logs, manuscripts). +# +# Per skaldsong's INV-006: process restart marks any 'running' generation +# rows 'partial' and never auto-resumes — deploy mid-run is safe; in-flight +# work looks to the SPA like a power outage. +# +# All tunables live in .env — edit that, not this file. + +services: + skaldsong-host: + image: ${SKALDSONG_IMAGE} + container_name: skaldsong + restart: unless-stopped + ports: + - "${SKALDSONG_BIND:-0.0.0.0}:${SKALDSONG_PORT}:8000" + environment: &skaldsong-env + # App always listens on 8000 internally; host port is the only knob. + + # Worldtree integration — JWT key is the shared-secret Bearer per + # Worldtree's HS256 path (same value as Worldtree's own + # WORLDTREE_SKALDSONG_USER_KEY). NEVER commit; .env on the server + # holds the real value. + SKALDSONG_BIFROST_JWT_KEY: "${SKALDSONG_BIFROST_JWT_KEY:-}" + + # URL Worldtree uses to call BACK to skaldsong's Bifrost endpoint + # at tool-dispatch time. This is skaldsong's host-side reachable + # URL from corviduo-dev's perspective — must match Worldtree's + # BIFROST_CLIENT_ALLOWED_HOSTS entry. Coordinated allowlist update + # required if host:port changes. + SKALDSONG_HOST_BIFROST_ENDPOINT_URL: "${SKALDSONG_HOST_BIFROST_ENDPOINT_URL:-}" + + # URL skaldsong uses to call Worldtree's conversation API. From + # ana-docker, this is corviduo-dev's IP:port LAN-direct. + SKALDSONG_WORLDTREE_API_URL: "${SKALDSONG_WORLDTREE_API_URL:-http://10.250.50.152:8080}" + + # CORS for the SPA. Comma-separated origins; include both the + # public hostname and any dev origins still in rotation. + SKALDSONG_HOST_CORS_ORIGINS: "${SKALDSONG_HOST_CORS_ORIGINS:-}" + + # TTS — Kokoro on irv-ml1, reached over WireGuard via ana-wg from + # ana-docker. Same URL works fleet-wide. + SKALDSONG_TTS_ENGINE: "${SKALDSONG_TTS_ENGINE:-kokoro}" + SKALDSONG_TTS_BASE_URL: "${SKALDSONG_TTS_BASE_URL:-http://10.100.79.3:8193}" + + # SPA static assets — baked into the image at /app/web/dist by the + # Dockerfile's web-builder stage. + SKALDSONG_HOST_STATIC_ASSETS_PATH: /app/web/dist + + # Persistent state paths inside the container. The db/ parent + # dir is the bind-mount target (not the file itself) so SQLite + # can write its WAL + SHM siblings. + SKALDSONG_DB_PATH: /app/state/db/skaldsong-ui.db + SKALDSONG_RUNS_DIR: /app/state/runs + volumes: + # db/ and runs/ are separate bind-mounts so runs/ can later move to + # a bigger volume without touching DB state. + - ${SKALDSONG_DB_DIR}:/app/state/db + - ${SKALDSONG_RUNS_DIR_HOST}:/app/state/runs + healthcheck: + test: ["CMD-SHELL", "python -c 'import urllib.request,sys; r=urllib.request.urlopen(\"http://127.0.0.1:8000/health\",timeout=3); sys.exit(0 if r.status==200 else 1)' || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - homepage.group=AI Systems + - homepage.name=Skaldsong + - homepage.icon=mdi-book-edit-outline + - homepage.description=Wizard + reader surface for multi-agent storytelling (Bifrost consumer of Worldtree) + - homepage.href=http://10.250.50.70:${SKALDSONG_PORT} + +networks: + default: + name: skaldsong_default