feat(hrafn): author the CI deploy that replaces the hand-rsync path
hrafn was handed to infra-ops for uptime ownership with no CI deploy and no commit provenance -- the image was always local/hrafn:v1 and the whole working tree lived in the compose directory. These two files fix both. They are authored here because infra-ops owns hrafn's uptime, but they belong in vh/hrafn; claude-bot is not a collaborator there, so they are handed to the repo holder rather than committed directly. - playbooks-deploy.yaml -> vh/hrafn playbooks/deploy.yaml - gitea-workflows-deploy.yaml -> vh/hrafn .gitea/workflows/deploy.yaml Design calls recorded in the README: the build context travels as one tarball rather than per-file upload steps (nevermore's pattern fails open when a new source file has no matching step), and the playbook refuses to deploy unless .env exists at 0600 -- a guard prompted by it arriving 0644 with a live bearer token in it. Validated with `elway --dry-run`, which caught a real interpolation bug during authoring. No new Actions secrets needed.
This commit is contained in:
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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 <host> --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"
|
||||
Reference in New Issue
Block a user