althing-chamber: scaffold deploy stack on ana-docker

Two-service compose (chamber + forseti sidecar daemon) sharing a single
SQLite store via bind-mount under /opt/docker/conf/althing-chamber/data.
eventbus.bridge_from_db is the cross-process glue — forseti's commits
reach chamber's SSE subscribers via the bridge.

Pattern matches task-board's build-on-host deploy:
  - elway playbook clones vh/althing into /opt/docker/build/
  - docker build -t althing-chamber:local . (no registry)
  - playbook uploads compose + seeds .env one-time, brings both
    services up, polls /health
  - Gitea Actions workflow lives in vh/althing; reference copy here.

Internal tooling — host port 7881 (chamber's default of 7878 collides
with task-board). LAN-direct, no Traefik. Container always listens on
8000 internally.

Scaffold will fail to bring the chamber container up healthy until
galdrabok-side commits land:
  - Dockerfile at vh/althing repo root (two-stage: uv-bookworm-slim
    build → python:3.12-slim runtime, locked per open_questions §2
    of the v1 contract).
  - GET /health endpoint on the chamber app (200, no DB read).
  - ALTHING_BIND / ALTHING_PORT env-var support in
    core.cli.chamber_serve / core.chamber.cli (env > config.yaml >
    defaults precedence).

Coordinated via althing thread 01KRMAK7RD7TP6C8DF4KXV31RT.
This commit is contained in:
2026-05-14 15:54:19 -07:00
parent c27761f608
commit 91d5417b0b
5 changed files with 426 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# Deploy althing-chamber (https://gitea.phasefinal.com/vh/althing) 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).
#
# Brings up TWO compose services from a single image:
# althing-chamber — FastAPI/HTMX web UI, port 7881 host → 8000 container
# althing-forseti — moderator daemon, no port
#
# Both processes share the SQLite store via the same bind-mount under
# /opt/docker/conf/althing-chamber/data. eventbus.bridge_from_db is the
# cross-process glue.
#
# Idempotent: rerunning is safe. Creates-gates skip work that's already
# done; `docker compose up -d` is itself idempotent (no restart unless
# compose content or env changed).
#
# Usage:
# scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml
# scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml --var ref=v0.1.0
#
# Prereqs on the target host:
# - Docker + docker compose plugin
# - Target user (lkraven) has git SSH access to gitea.phasefinal.com
# — either SSH key authorized in gitea, or the repo is HTTPS-reachable
# if you swap `repo_url` below.
# - Target user is in the `docker` group.
vars:
repo_url: git@gitea.phasefinal.com:vh/althing.git
ref: main
build_dir: /opt/docker/build/althing-chamber
image_tag: althing-chamber:local
compose_dir: /opt/docker/compose/althing-chamber
data_dir: /opt/docker/conf/althing-chamber/data
host_port: "7881"
steps:
# ── host-side directory prep ─────────────────────────────────────────
- name: Ensure /opt/docker/build parent exists
shell: mkdir -p /opt/docker/build
sudo: true
creates: /opt/docker/build
- name: Chown /opt/docker/build to lkraven (only if mkdir'd by root above)
shell: chown lkraven:lkraven /opt/docker/build
sudo: true
when: '[ "$(stat -c %U /opt/docker/build)" != lkraven ]'
# ── fetch / sync source ─────────────────────────────────────────────
- name: Clone althing repo if absent
# Auto-accept the first-run host key so the playbook doesn't hang
# prompting for yes/no.
shell: GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new" git clone {{ repo_url }} {{ build_dir }}
creates: "{{ build_dir }}/.git"
- name: Fetch from origin
shell: cd {{ build_dir }} && git fetch --quiet origin
- name: Reset working tree to {{ ref }}
# Accept either a branch name (resolves via origin/<ref>) or a
# full/short SHA (resolves directly). CI passes the triggering
# commit SHA via --var ref=${{ github.sha }}; manual runs pass
# branch names like main / v0.1.0.
shell: |
cd {{ build_dir }}
if sha=$(git rev-parse --verify --quiet "origin/{{ ref }}^{commit}"); then :;
elif sha=$(git rev-parse --verify --quiet "{{ ref }}^{commit}"); then :;
else echo "elway: ref not found: {{ ref }}" >&2; exit 1; fi
git reset --hard "$sha"
# Report ok (no-change) when the tree was already at the requested
# ref — saves a noisy CHANGED status line on no-op reruns.
changed_when: '[ "$(cd {{ build_dir }} && git rev-parse HEAD)" != "$(cd {{ build_dir }} && (git rev-parse --verify --quiet "origin/{{ ref }}^{commit}" || git rev-parse --verify --quiet "{{ ref }}^{commit}"))" ]'
# ── image build ─────────────────────────────────────────────────────
- name: Build image {{ image_tag }}
shell: cd {{ build_dir }} && docker build -t {{ image_tag }} .
# Docker build reuses layer cache and is fast on reruns, but it
# always runs — we can't cheaply know up-front whether anything
# downstream has changed. Leave it in the always-run lane; Docker
# itself handles the no-op efficiently.
# ── compose + data dirs ─────────────────────────────────────────────
- name: Ensure compose dir exists
shell: mkdir -p {{ compose_dir }}
creates: "{{ compose_dir }}"
- name: Ensure data dir exists
# Single bind-mount shared between chamber + forseti. Created as
# lkraven (uid 1000 on these hosts), matching the container's `app`
# user — no chown dance needed.
shell: mkdir -p {{ data_dir }}
creates: "{{ data_dir }}"
# ── deploy compose files ────────────────────────────────────────────
- name: Upload compose.yaml
upload:
src: stacks/althing-chamber/compose.yaml
dest: "{{ compose_dir }}/compose.yaml"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/althing-chamber/.env.example
dest: "{{ compose_dir }}/.env"
mode: "0644"
when: "[ ! -f {{ compose_dir }}/.env ]"
# ── bring up + wait for ready ───────────────────────────────────────
- name: docker compose up -d
shell: cd {{ compose_dir }} && docker compose up -d
- name: Wait for chamber /health to respond
# Chamber's healthcheck is internal (inside the container's network);
# this host-side poll confirms the published port is reachable too.
# Short retry loop — docker compose up returns before the FastAPI
# app finishes booting.
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: chamber /health returns 200
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/health
changed_when: "false"
- name: chamber container running
shell: docker ps --filter name=^/althing-chamber$ --format '{{.Status}}' | grep -q '^Up'
changed_when: "false"
- name: forseti container running
shell: docker ps --filter name=^/althing-forseti$ --format '{{.Status}}' | grep -q '^Up'
changed_when: "false"
+27
View File
@@ -0,0 +1,27 @@
# althing-chamber 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. Built locally from the vh/althing git repo by the playbook.
ALTHING_IMAGE=althing-chamber:local
# Host port exposing the chamber UI (container always listens on 8000
# internally). Internal-only — no Traefik.
# Chamber's compiled-in default port is 7878 but that collides with
# task-board's 7878 on the same host. 7881 is the canonical fleet slot,
# adjacent to task-board:7878 and vor:7879.
ALTHING_PORT=7881
# Bind address for the host port. 0.0.0.0 = LAN-reachable (default for an
# internal-only tool).
ALTHING_BIND=0.0.0.0
# Host path for SQLite + state. Container runs as uid 1000 (matches
# lkraven on these hosts) so the playbook's mkdir without sudo produces
# a writable dir.
#
# This single dir is the only persistent state — althing's SQLite DB,
# read_cursors, notification_state, hand_queue, floor_grants all live
# here. Restic backs it up via the standard /opt/docker tree.
ALTHING_DATA_DIR=/opt/docker/conf/althing-chamber/data
+96
View File
@@ -0,0 +1,96 @@
# althing-chamber
Web UI + moderator daemon for the althing inter-agent message bus.
Two services share a single SQLite store via a bind-mount; the chamber
serves FastAPI/HTMX, the forseti daemon runs the moderation + curation
loops. Cross-process glue is `eventbus.bridge_from_db` polling the same
DB both processes write.
**Server:** ana-docker
**URL:** `http://10.250.50.70:7881` (configurable via `.env`)
**Upstream repo:** [vh/althing](https://gitea.phasefinal.com/vh/althing)
**Image:** `althing-chamber:local` — built on the host from the git repo
by the deploy playbook. Not pulled from a registry.
## Services in this stack
| Container | Role | Port | Healthcheck |
|---|---|---|---|
| `althing-chamber` | FastAPI/HTMX web UI; SSE subscribers; `/health` endpoint | host 7881 → container 8000 | `python urllib /health` |
| `althing-forseti` | Moderator + curator daemon; writes events that chamber's bridge picks up | — (no HTTP) | none (process-up signal only) |
Both use the same `${ALTHING_IMAGE}`; the `command:` line in compose picks
which entrypoint (`althing-chamber` vs `althing-forseti`) runs in each
container.
## Deploy
Two paths — automated (preferred) and manual (escape hatch / first-time).
### Automated (Gitea Actions, push-to-main)
The vh/althing repo ships `.gitea/workflows/deploy.yaml`. Every push to
main + manual `workflow_dispatch` triggers the elway playbook below
pinned to the triggering commit SHA. A reference copy of the workflow
lives next to this README at
[`gitea-workflow-deploy.yaml.example`](gitea-workflow-deploy.yaml.example);
the canonical source is in the vh/althing repo. The example header
lists the two repo secrets required (`DEPLOY_SSH_KEY`,
`MGMT_REPO_TOKEN`).
### Manual (elway from a workstation)
```bash
# First deploy (or update to latest main)
scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml
# Pin to a specific ref (tag, branch, or commit SHA)
scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml --var ref=v0.1.0
```
## Path layout (on ana-docker)
| Host path | Container path | Purpose | Restic? |
|---|---|---|---|
| `/opt/docker/build/althing-chamber/` | — | git checkout used as docker build context | excluded |
| `/opt/docker/compose/althing-chamber/` | — | compose.yaml + .env | included (via `/opt/docker`) |
| `/opt/docker/conf/althing-chamber/data/` | `/app/data` | SQLite + state for BOTH services | **included** |
## Network model
Internal tooling, LAN-only. Chamber's container port 8000 is published
on the host at `0.0.0.0:7881` (configurable via `ALTHING_BIND` /
`ALTHING_PORT`); access is direct via `http://10.250.50.70:7881`. No
Traefik, no TLS terminator, no public hostname.
Forseti has no port — it's a daemon. The two services communicate only
via the shared SQLite file under the `${ALTHING_DATA_DIR}` bind-mount;
no docker network coupling beyond compose's default bridge.
## Env-var contract
| Var | In env? | In `~/.althing/config.yaml`? | Notes |
|---|---|---|---|
| `ALTHING_ROOT` | ✓ (set in compose) | n/a | Overrides the default `~/.althing/` dir. Container sets `/app/data`. |
| `ALTHING_DB` | ✓ (set in compose) | n/a | Explicit SQLite path. Defaults to `${ALTHING_ROOT}/althing.db`. |
| `ALTHING_BIND` | ✓ (deploy-time) | also accepted | Bind address for the chamber HTTP server. Container always sets 0.0.0.0 internally. |
| `ALTHING_PORT` | ✓ (deploy-time) | also accepted | Chamber listens here internally (always 8000 inside the container). |
Env-var support for `ALTHING_BIND` / `ALTHING_PORT` was added on the
galdrabok side as part of the container-deploy cycle (env > config.yaml
> defaults precedence).
## First-deploy sequence (when galdrabok's Dockerfile lands)
1. Generate a deploy keypair on ana-docker (private stays on host;
pubkey lands in `~lkraven/.ssh/authorized_keys`).
2. Wire two secrets in `vh/althing` Actions settings:
- `DEPLOY_SSH_KEY` — the private key from step 1.
- `MGMT_REPO_TOKEN` — Gitea PAT with `read:repository` on this repo,
used by the workflow to clone the management repo for the playbook.
3. Push to vh/althing's main branch (or trigger `workflow_dispatch`);
the Actions runner clones both repos, configures SSH, runs
`scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml --var ref=$SHA`.
4. Playbook: clones into `/opt/docker/build/althing-chamber`, builds
the image, uploads compose + .env (one-time seed), brings both
services up, polls `/health` until 200.
+76
View File
@@ -0,0 +1,76 @@
# althing-chamber + forseti — web UI + moderator daemon for the althing
# inter-agent message bus.
#
# Two services share the althing SQLite store via a single bind-mount:
# althing-chamber — FastAPI/HTMX app (port 7881 host → 8000 container)
# althing-forseti — moderator daemon (no port; cross-process glue via the DB)
#
# eventbus.bridge_from_db is load-bearing — forseti's DB commits reach
# chamber's SSE subscribers via the bridge, not via shared memory or IPC.
# Both processes read+write the same `~/.althing/althing.db` SQLite file
# at /app/data/althing.db inside the container, backed by the host's
# /opt/docker/conf/althing-chamber/data bind-mount.
#
# Image is built on the host from the vh/althing git repo by the deploy
# playbook (`playbooks/deploy-althing-chamber.yaml`), which clones into
# /opt/docker/build/althing-chamber and runs `docker build -t
# althing-chamber:local .` before installing this compose and bringing
# both services up. No registry.
#
# Internal tooling — accessed directly at http://10.250.50.70:7881 over
# the LAN; does NOT traverse Traefik. State persists under
# /opt/docker/conf/althing-chamber/data on the host.
#
# All tunables live in .env — edit that, not this file.
services:
althing-chamber:
image: ${ALTHING_IMAGE}
container_name: althing-chamber
restart: unless-stopped
ports:
- "${ALTHING_BIND:-0.0.0.0}:${ALTHING_PORT}:8000"
environment:
# ALTHING_ROOT moves the entire ~/.althing dir; ALTHING_DB additionally
# pins the SQLite path explicitly. Both point inside the bind-mount.
- ALTHING_ROOT=/app/data
- ALTHING_DB=/app/data/althing.db
# ALTHING_BIND / ALTHING_PORT — galdrabok-side env-var precedence
# (env > config.yaml > defaults) is being added in the same cycle
# as this scaffold lands. Container always listens on 8000 internally;
# the host port mapping above is the only externally-visible knob.
- ALTHING_BIND=0.0.0.0
- ALTHING_PORT=8000
volumes:
- ${ALTHING_DATA_DIR}:/app/data
command: ["althing-chamber"]
healthcheck:
# Liveness probe — chamber's /health endpoint returns 200 with no DB
# read (true liveness, not readiness). galdrabok adds this endpoint
# in the same cycle as this scaffold; container will crashloop on
# healthcheck until that lands.
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=Toolchain
- homepage.name=althing chamber
- homepage.icon=mdi-bullhorn
- homepage.description=Web UI for the althing inter-agent message bus
- homepage.href=http://10.250.50.70:${ALTHING_PORT}
althing-forseti:
image: ${ALTHING_IMAGE}
container_name: althing-forseti
restart: unless-stopped
environment:
- ALTHING_ROOT=/app/data
- ALTHING_DB=/app/data/althing.db
volumes:
- ${ALTHING_DATA_DIR}:/app/data
command: ["althing-forseti"]
# No healthcheck — the forseti CLI doesn't expose one. Liveness signal
# for ops is "container hasn't exited" + chamber-side observation that
# bridge_from_db events are flowing.
@@ -0,0 +1,91 @@
# Gitea Actions workflow for althing-chamber.
#
# THIS FILE LIVES IN THE VH/ALTHING REPO, NOT HERE.
# Copy to vh/althing:.gitea/workflows/deploy.yaml and commit.
# (The canonical copy lives in the althing repo; this file is a
# reference for what shape the workflow takes.)
#
# What it does on every push to main (and on manual workflow_dispatch):
# 1. Checks out althing itself (the triggering repo).
# 2. Checks out vh/esh-pfi-infrastructure to pick up the elway
# playbook and helper scripts.
# 3. Configures SSH so elway can reach ana-docker.
# 4. Runs `scripts/elway ana-docker --playbook playbooks/deploy-althing-chamber.yaml`
# pinning to the commit SHA that triggered the workflow.
#
# Required Actions secrets (configure under
# https://gitea.phasefinal.com/vh/althing/settings/actions/secrets,
# or org-level for reuse across repos):
#
# DEPLOY_SSH_KEY Private SSH key whose pubkey is in
# ~lkraven/.ssh/authorized_keys on ana-docker.
# Used by the runner to invoke the elway playbook.
# Generate fresh; don't reuse a personal key.
#
# MGMT_REPO_TOKEN Gitea PAT (read:repository scope) on
# vh/esh-pfi-infrastructure, used to clone the
# management repo. Generate at
# https://gitea.phasefinal.com/-/user/settings/applications.
name: Deploy althing-chamber
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
# `pfi-fleet` matches the central runner on ana-docker. Pin to
# `ana-docker` instead if you want to refuse running on a future
# site-local runner. The runner's label embeds a default image
# (node:20-bookworm-slim) — has node + git out of the box, so
# actions/checkout@v4 (a JS action) works without a custom
# container. We just apt-install python3 + pyyaml for elway.
runs-on: pfi-fleet
steps:
- name: Install playbook prerequisites
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
python3 python3-yaml openssh-client
rm -rf /var/lib/apt/lists/*
- name: Checkout althing (triggering repo)
uses: actions/checkout@v4
- name: Checkout management repo (eshpfi-management)
uses: actions/checkout@v4
with:
repository: vh/esh-pfi-infrastructure
token: ${{ secrets.MGMT_REPO_TOKEN }}
path: _mgmt
- name: Configure SSH to ana-docker
run: |
mkdir -p ~/.ssh
# The DEPLOY_SSH_KEY secret is the full private key contents,
# newline-terminated. ssh refuses keys that aren't 0600.
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# ssh_config alias so elway resolves "ana-docker" the same
# way it would on a workstation. accept-new is fine for a
# fresh job container — host key gets cached for the lifetime
# of this job only.
cat > ~/.ssh/config <<'EOF'
Host ana-docker
HostName 10.250.50.70
User lkraven
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new
EOF
chmod 600 ~/.ssh/config
- name: Deploy althing-chamber (elway playbook, pinned to this commit)
working-directory: _mgmt
run: |
scripts/elway ana-docker \
--playbook playbooks/deploy-althing-chamber.yaml \
--var ref=${{ github.sha }}