diff --git a/stacks/hrafn/ci/README.md b/stacks/hrafn/ci/README.md new file mode 100644 index 0000000..8039793 --- /dev/null +++ b/stacks/hrafn/ci/README.md @@ -0,0 +1,56 @@ +# hrafn CI deploy — authored here, lands in `vh/hrafn` + +These two files replace hrafn's hand-rsync deploy. They are **authored and +version-controlled here** because infra-ops owns hrafn's uptime, but they +**belong in the `vh/hrafn` repo** — infra-ops has no write access to it +(claude-bot is not a collaborator), so they are handed to the repo holder +rather than committed directly. + +| file here | destination in `vh/hrafn` | +|---|---| +| `playbooks-deploy.yaml` | `playbooks/deploy.yaml` | +| `gitea-workflows-deploy.yaml` | `.gitea/workflows/deploy.yaml` | + +Keep this copy in sync if the deployed version changes, or delete it once +infra-ops has write access to `vh/hrafn` and the repo copy is canonical. + +## What the change buys + +The pre-CI shape was: rsync a working tree into `/opt/docker/compose/hrafn/`, +then `docker compose build && up`. Two problems, both fixed here. + +1. **No provenance.** The image was always `local/hrafn:v1`, so nothing on + the box could answer "what commit is running". The image is now tagged + with the commit SHA, and `/opt/docker/compose/hrafn/.deployed` records + the SHA and timestamp. Rollback becomes a retag. +2. **The whole repo lived in the compose directory** — `tests/`, `docs/`, + `ROADMAP.md`, `persistent-memory.md`, `CLAUDE.md`. Only the build context + ships now (`Dockerfile`, `compose.yaml`, `pyproject.toml`, `README.md`, + `src/`). + +## Two design calls worth knowing + +- **Tarball, not per-file upload steps.** nevermore's playbook enumerates + every source file as its own `upload:` step. That is explicit, but it + fails *open*: add `src/hrafn/newthing.py`, forget the matching step, and + the deploy silently ships without it. hrafn's build context travels as one + archive so it cannot go partial. +- **`.env` is never deployed.** It is host-owned, `0600`, and holds the + bearer token. The playbook *refuses to run* if it is missing or not `0600` + — a guard added because the file arrived at `0644` on handoff. + +## Validation + +The playbook parses and interpolates clean under elway's own parser: + +```bash +scripts/elway ana-docker --playbook stacks/hrafn/ci/playbooks-deploy.yaml \ + --var hrafn_sha=abc123def456 --dry-run +``` + +That dry-run is worth running after any edit — it caught a real bug during +authoring, where a comment containing a literal `{{ ... }}` identifier was +picked up by elway's variable substitution and failed the run. + +No new Actions secrets are required: `DEPLOY_SSH_KEY` and `MGMT_REPO_TOKEN` +already exist at user scope on `vh` from the nevermore/task-board wiring. diff --git a/stacks/hrafn/ci/gitea-workflows-deploy.yaml b/stacks/hrafn/ci/gitea-workflows-deploy.yaml new file mode 100644 index 0000000..2e2020b --- /dev/null +++ b/stacks/hrafn/ci/gitea-workflows-deploy.yaml @@ -0,0 +1,87 @@ +# Gitea Actions workflow for hrafn. +# +# Runs on every push to main (and on manual workflow_dispatch). Drives +# the in-repo elway playbook (playbooks/deploy.yaml) — that file is the +# single source of truth for "how hrafn is deployed", manual or automated. +# +# Replaces the hand-rsync deploy. Two things change as a result: +# - the image is tagged with the commit SHA rather than a fixed `v1`, +# so the host can answer "what is running" and a rollback is a retag +# - only the build context ships (Dockerfile, compose.yaml, +# pyproject.toml, README.md, src/) instead of the whole working tree, +# so tests/, docs/, persistent-memory.md and friends stop living in +# /opt/docker/compose/hrafn on the server +# +# Required Actions secrets — both already exist at user scope on vh from +# the nevermore/task-board wiring, so there is nothing new to provision: +# +# DEPLOY_SSH_KEY Private SSH key authorized for lkraven@ana-docker. +# MGMT_REPO_TOKEN Gitea PAT (read:repository) on vh/esh-pfi-infrastructure. +# Needed to clone elway from the management repo. +# +# NOT handled here: .env. It is host-owned, 0600, holds the bearer token, +# and is provisioned once from vault item ana-docker/hrafn/bearer-token. +# The playbook refuses to deploy if it is missing or not 0600. + +name: Deploy hrafn + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + 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 tar + rm -rf /var/lib/apt/lists/* + + - name: Checkout hrafn (triggering repo) + uses: actions/checkout@v4 + + - name: Checkout management repo (for elway) + 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 + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + 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: Build the deploy context + # Only what the Dockerfile actually consumes, plus compose.yaml. + # Deliberately excludes tests/, docs/, persistent-memory.md, + # ROADMAP.md, CLAUDE.md, LICENSE and .env — none of them belong + # in a production compose directory. + run: | + mkdir -p dist + tar czf dist/hrafn-context.tgz \ + Dockerfile compose.yaml pyproject.toml README.md src + echo "context contents:" + tar tzf dist/hrafn-context.tgz + + - name: Deploy hrafn (in-repo elway playbook) + # hrafn_sha becomes the image tag and is written to + # /opt/docker/compose/hrafn/.deployed on the host. + run: | + _mgmt/scripts/elway ana-docker \ + --playbook playbooks/deploy.yaml \ + --var hrafn_sha=${GITHUB_SHA::12} diff --git a/stacks/hrafn/ci/playbooks-deploy.yaml b/stacks/hrafn/ci/playbooks-deploy.yaml new file mode 100644 index 0000000..a8cbd2a --- /dev/null +++ b/stacks/hrafn/ci/playbooks-deploy.yaml @@ -0,0 +1,109 @@ +# Deploy hrafn (fleet browser-fetch service) to a Docker host. +# +# Single source of truth for "how hrafn is deployed" — CI and manual runs +# take the same path. Modelled on nevermore's playbook, with two deliberate +# differences noted below. +# +# Usage (from this repo's root): +# elway --playbook playbooks/deploy.yaml +# +# Requires a build-context tarball at dist/hrafn-context.tgz. The CI +# workflow builds it; for a manual run, build it the same way: +# mkdir -p dist && tar czf dist/hrafn-context.tgz \ +# Dockerfile compose.yaml pyproject.toml README.md src +# +# DIFFERENCE 1 — tarball instead of per-file upload steps. nevermore +# enumerates every source file as its own upload step. That is explicit, +# but it fails OPEN: add a new src/hrafn/*.py and forget the matching +# step, and the deploy silently ships without it. hrafn's build context +# is a whole package, so it travels as one archive and cannot go partial. +# +# DIFFERENCE 2 — the image is tagged with the commit SHA, not a fixed +# `v1`. Before this, nothing on the host could answer "what is running"; +# now the image tag IS the answer, and rolling back is retagging. +# +# NOT DEPLOYED BY THIS PLAYBOOK: .env. It is host-owned, 0600, and holds +# the bearer token. Uploading it would either clobber the live token or +# leak it into the repo. It is provisioned once by hand from the vault +# item `ana-docker/hrafn/bearer-token`. + +vars: + compose_dir: /opt/docker/compose/hrafn + # Overridden by CI with the triggering commit. `manual` marks a + # hand-run deploy so an un-provenanced image is obvious on the host. + hrafn_sha: manual + +steps: + # ── preconditions ─────────────────────────────────────────────────── + + - name: Verify compose dir exists + writable + shell: test -w {{ compose_dir }} + changed_when: "false" + + - name: Verify .env is present and 0600 (deploy must not create it) + shell: | + test -f {{ compose_dir }}/.env || { echo "MISSING .env — provision it from vault item ana-docker/hrafn/bearer-token"; exit 1; } + perms=$(stat -c %a {{ compose_dir }}/.env) + test "$perms" = "600" || { echo "REFUSING: .env is $perms, expected 600 (it holds the bearer token)"; exit 1; } + changed_when: "false" + + - name: Verify traefik-net exists (consumers reach hrafn on it) + shell: docker network inspect traefik-net >/dev/null 2>&1 + changed_when: "false" + + # ── ship the build context ────────────────────────────────────────── + + - name: Upload build context + upload: + src: dist/hrafn-context.tgz + dest: "{{ compose_dir }}/.hrafn-context.tgz" + mode: "0600" + + - name: Unpack build context + # Overwrites tracked files in place; leaves .env and any host-only + # state alone because the archive does not contain them. + shell: tar xzf {{ compose_dir }}/.hrafn-context.tgz -C {{ compose_dir }} && rm -f {{ compose_dir }}/.hrafn-context.tgz + + # ── build + start ─────────────────────────────────────────────────── + + - name: Build image tagged with the commit + shell: cd {{ compose_dir }} && HRAFN_TAG={{ hrafn_sha }} docker compose build + + - name: Start/replace the container on the new image + shell: cd {{ compose_dir }} && HRAFN_TAG={{ hrafn_sha }} docker compose up -d + + - name: Record what is deployed + # The provenance fix. Anyone on the box can now answer "what commit + # is this" without asking the person who last rsync'd. + shell: | + printf 'sha=%s\ndeployed_at=%s\n' "{{ hrafn_sha }}" "$(date -Is)" > {{ compose_dir }}/.deployed + chmod 644 {{ compose_dir }}/.deployed + +verify: + - name: Container reports healthy + # start_period is 20s and the interval is 30s, so allow two cycles + # before calling it a failure rather than a slow start. + shell: | + # elway's variable regex only matches an identifier starting with a + # letter or underscore, so docker's .State Go template below passes + # through untouched and needs no escaping. + for i in $(seq 1 20); do + s=$(docker inspect hrafn --format '{{.State.Health.Status}}' 2>/dev/null || echo missing) + [ "$s" = "healthy" ] && { echo "healthy after ${i}0s"; exit 0; } + sleep 10 + done + echo "still $s after 200s"; docker logs --tail 40 hrafn; exit 1 + changed_when: "false" + + - name: Readiness endpoint answers on the shared network + # Proves consumers can actually reach it, not just that the process + # is up — hrafn has no published host port, so this has to run from + # inside traefik-net. + shell: > + docker run --rm --network traefik-net curlimages/curl:latest + -sf -m 10 -o /dev/null -w '%{http_code}\n' http://hrafn:8080/readyz + changed_when: "false" + + - name: Deployed SHA matches what we just shipped + shell: grep -q "^sha={{ hrafn_sha }}$" {{ compose_dir }}/.deployed + changed_when: "false"