skaldsong: scaffold compose stack + deploy playbook for ana-docker

Registry-pull pattern matching Worldtree: CI on vh/skaldsong builds and
pushes gitea.phasefinal.com/vh/skaldsong:<sha>, 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.
This commit is contained in:
2026-05-18 22:01:29 -07:00
parent 8f50d702d1
commit 55e2e836a8
4 changed files with 384 additions and 0 deletions
+117
View File
@@ -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:<sha>` (+ :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=<sha>
#
# # 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=<sha>; 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"
+53
View File
@@ -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:<sha>
# Workflow passes the SHA via `scripts/elway ... --var ref=<sha>`; 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
+122
View File
@@ -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:<sha>` — 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=<sha>
# 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=<sha>` → 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=<sha>` 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).
+92
View File
@@ -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