diff --git a/playbooks/deploy-althing-chamber.yaml b/playbooks/deploy-althing-chamber.yaml new file mode 100644 index 0000000..9eaf381 --- /dev/null +++ b/playbooks/deploy-althing-chamber.yaml @@ -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/) 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" diff --git a/stacks/althing-chamber/.env.example b/stacks/althing-chamber/.env.example new file mode 100644 index 0000000..abfddb2 --- /dev/null +++ b/stacks/althing-chamber/.env.example @@ -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 diff --git a/stacks/althing-chamber/README.md b/stacks/althing-chamber/README.md new file mode 100644 index 0000000..ce60991 --- /dev/null +++ b/stacks/althing-chamber/README.md @@ -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. diff --git a/stacks/althing-chamber/compose.yaml b/stacks/althing-chamber/compose.yaml new file mode 100644 index 0000000..84ef02a --- /dev/null +++ b/stacks/althing-chamber/compose.yaml @@ -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. diff --git a/stacks/althing-chamber/gitea-workflow-deploy.yaml.example b/stacks/althing-chamber/gitea-workflow-deploy.yaml.example new file mode 100644 index 0000000..91480b8 --- /dev/null +++ b/stacks/althing-chamber/gitea-workflow-deploy.yaml.example @@ -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 }}