Files
kvitt/PLAN-M2c-pi.md
T
jacobh2 5e1be38364 Plans and start prompts for every milestone; SPEC v0.2
PLAN-<id>.md for U1, U2, M1, M2a, M2b, M2c-dev, M2c-pi, M3, M4, M5, M6,
M7a-M7f, M8, M9, M10, M11, each with the eleven fixed sections, and a
start prompt per plan in prompts/ plus prompts/README.md (dependency
order and parallel waves).

SPEC v0.2: owner answers D-001 (development-first, M2c split into
M2c-dev and M2c-pi, Gitea registry), D-002 (local backups deleted once
on Drive), D-003 (Splitwise CSV format, English and Swedish variants,
category mapping), OQ-1, OQ-2, OQ-4 answered; M1's readings of
ambiguous contract points stated explicitly in §14.3 to §14.5.
HANDOFF §2 lists the plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:05:34 +02:00

14 KiB
Raw Blame History

PLAN-M2c-pi: Infra on the Pi (D-001)

Runs after M8 lands and before M10 (D-001, §11.1).

1. Goal

Deploy Kvitt's production stack on the Raspberry Pi 3: Caddy with the Let's Encrypt certificate synced from the Home Assistant Pi, the server, cert-sync and nightly Drive backups, with images in the Gitea registry at http://gitea.lan. Write the runbooks so the owner can provision, deploy, publish the APK and restore without an agent.

2. Read first

  1. SPEC §10 Deployment & ops, all of it: intro "Development first (D-001)", §10.1 Hosts and network, §10.2 Compose stack, §10.3 Images, registry and build, §10.4 Server configuration and CLI, §10.5 TLS (10.5.1 Certificate sync, 10.5.2 Caddyfile, 10.5.3 Fallback), §10.6 APK hosting and release, §10.7 Firebase setup (step 7), §10.9 Backups and restore, §10.10 Operations.
  2. SPEC §8.5 Transport, §8.9 Server hardening, §8.7 Privacy and logging.
  3. SPEC §4 FR-18 Backups and restore, FR-20 Health (AC-20.2).
  4. SPEC §11.2 (rows M2c-dev, M2c-pi, M10), §11.3 (note M2c-dev / M2c-pi), §12 (OQ-1, OQ-3, OQ-4), §14.7.
  5. docs/decisions.md D-001, D-002.
  6. Files: everything under infra/ from M2c-dev (infra/dev/**, infra/backup/**, infra/env.example), server/Dockerfile, .gitignore, CLAUDE.md (EXECUTION PROTOCOLS: Stay inside the repository, Stop and ask; THE OWNER).

3. Already established

  • Hosts: Kvitt Pi (Raspberry Pi 3, 1 GB, 64-bit Raspberry Pi OS Lite, Docker Engine with compose plugin, static LAN address); Home Assistant Pi owns 80/443 and the certificate; ASUS router forwards external TCP 8443 to Kvitt Pi 8443; the Pi never builds anything (§10.1).
  • Registry: Gitea at http://gitea.lan, plain HTTP, home Wi-Fi only; the Pi's Docker daemon trusts it via insecure-registries; images will be built by Gitea Actions from the owner's example workflow; until then infra/build-images.sh on the Mac pushes to gitea.lan (D-001, §10.1, §10.3). Gitea credentials, if needed, come from the owner and live in infra/secrets/ (§10.1).
  • Compose stack exactly as §10.2: services caddy, server, cert-sync, backup, their images, volumes, memory limits, healthchecks, depends_on, restart: unless-stopped, json-file logging 10m × 3, TZ=Europe/Stockholm, network kvitt; .env keys exactly KVITT_REGISTRY, KVITT_TAG, HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR, RCLONE_REMOTE (default gdrive:), RCLONE_FOLDER_ID.
  • Files deployed to /opt/kvitt/compose.yaml, /opt/kvitt/.env, secrets in /opt/kvitt/secrets/ (§10.2, §10 intro).
  • Images: server per §10.3 (M2a's Dockerfile); cert-sync = alpine + openssh-client, curl, infra/cert-sync/sync.sh under crond; backup from M2c-dev (§10.3).
  • Scripts: infra/build-images.sh [tag] (buildx linux/arm64 --push, tag default short SHA, also latest, refuses a dirty tree); infra/deploy.sh <tag> (copy compose and Caddyfile over SSH, set KVITT_TAG, pull, up, wait healthy, print /health; triggers a backup and waits for last-success before a deploy with a migration); infra/publish-apk.sh (assembleRelease via tools/gradle-serial.sh, SHA-256 and size, copy to apk/kvitt-<version_code>.apk, atomic apk/latest.json, keep newest 5) (§10.3, §10.6, §10.10).
  • cert-sync: at start and daily 04:00; SSH with StrictHostKeyChecking=yes; restrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem" where the HA SSH setup allows it, and the plan records which variant the add-on supports; installs only valid, newer, changed PEMs with mode 0640, reloads Caddy through its admin API; failures keep the old certificate (§10.5.1).
  • Caddyfile exactly §10.5.2; Caddy must not log Authorization (verify); fallback §10.5.3 documented in infra/README.md, not deployed (§10.5).
  • Backups: daily 03:30; Drive root folder id 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV as RCLONE_FOLDER_ID / --drive-root-folder-id; rclone copy --immutable, never sync, never delete on Drive; local files deleted as soon as both are confirmed on Drive with rclone lsf (D-002, §10.9, OQ-4). rclone remote [gdrive], type = drive, scope = drive, token from rclone authorize "drive" (§10.9).
  • FCM key on the Pi at /opt/kvitt/secrets/fcm-service-account.json, mode 0440, group 10001 (§10.7 step 7).
  • Restore steps are §10.9 1 to 7, documented in infra/RESTORE.md (§10.9).

4. Owned paths

From SPEC §14.7, verbatim: infra/** (owner "M2c-dev, then M2c-pi (D-001)"; after landing: "M10 updates runbooks").

Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator.

Open points the orchestrator must settle in the start prompt's notes: (a) agent access to the Kvitt Pi over SSH from the Mac (key-based login) and permission to act outside the repository (the Pi, the HA Pi, Gitea, Google Drive) per CLAUDE.md "Stay inside the repository"; (b) where the Gitea Actions workflow file lives (Gitea reads .gitea/workflows/ at a repository root, outside infra/**) and whether the repository is mirrored to Gitea (a push needs the owner's explicit ask); (c) file modes and owners on the Pi for ha_ssh_key, ha_known_hosts and rclone.conf, and the uid the cert-sync and backup containers run as (only the FCM key's mode is in §10.7).

5. Deliverables

  • infra/compose.yaml: the §10.2 stack.
  • infra/Caddyfile: §10.5.2 verbatim.
  • infra/env.example: updated for the Pi (KVITT_REGISTRY on gitea.lan, RCLONE_FOLDER_ID 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV; no secrets).
  • infra/cert-sync/Dockerfile, infra/cert-sync/sync.sh: §10.5.1.
  • infra/backup/backup.sh: D-002 deletion after Drive confirmation (kept local mode from M2c-dev).
  • infra/build-images.sh, infra/deploy.sh, infra/publish-apk.sh: §10.3, §10.6, §10.10.
  • Gitea Actions workflow from the owner's example (path per open point b).
  • infra/README.md: Pi provisioning, Docker install, gitea.lan registry trust, router forwarding, HA SSH add-on key setup and which authorized_keys variant it supports, the §10.5.3 fallback, deploy and publish, operations of §10.10.
  • infra/RESTORE.md: §10.9 restore steps 1 to 7, with exact commands.
  • On the Pi (not in git): /opt/kvitt/compose.yaml, /opt/kvitt/.env, /opt/kvitt/secrets/{fcm-service-account.json, ha_ssh_key, ha_known_hosts, rclone.conf}.
  • In infra/secrets/ on the Mac (git-ignored, main checkout): ha_ssh_key, ha_ssh_key.pub, ha_known_hosts, rclone.conf, Gitea credentials if any.

6. Steps

  1. Worktree. From the main checkout /Users/jhag/projects/kvitt: git worktree add .claude/worktrees/agent-m2c-pi -b agent-m2c-pi main, then in .claude/worktrees/agent-m2c-pi: git reset --hard main. Copy infra/secrets/ from the main checkout (mkdir -p infra/secrets && cp -R /Users/jhag/projects/kvitt/infra/secrets/. infra/secrets/) and android/local.properties (needed by publish-apk.sh only). Run tools/install-git-hooks.sh.
  2. Read §2 in order. Confirm OQ-3 is answered in the start prompt (or SPEC §12); without it only steps 3 to 6 can run; stop before step 7 and report.
  3. Versions: resolve from official sources and record: caddy image version and digest (Docker Hub official image), alpine tag and digest, openssh-client and curl package versions in that Alpine release, Docker Engine install method for Raspberry Pi OS (docs.docker.com, Debian/Raspberry Pi OS page).
  4. Write infra/compose.yaml, infra/Caddyfile, infra/cert-sync/**, the D-002 change in infra/backup/backup.sh, the three scripts, infra/README.md, infra/RESTORE.md. Generate the dedicated cert-sync key in the main checkout: ssh-keygen -t ed25519 -N '' -C kvitt-cert-sync -f /Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key.
  5. Run docker compose -f infra/compose.yaml config (with a .env made from infra/env.example in a temp location, never committed) and shellcheck infra/**/*.sh (globstar on).
  6. Gitea Actions: write the workflow from the owner's example (H-2); until it exists, build with infra/build-images.sh on the Mac (no Gradle build running) and push to gitea.lan.
  7. Pi provisioning over SSH (needs OQ-3, H-1, H-3, H-4): create /opt/kvitt/ and /opt/kvitt/secrets/; copy the secrets with the modes of open point (c) and §10.7 step 7; install insecure-registries for gitea.lan per H-4.
  8. cert-sync: ssh-keyscan -p <HA port> <HA host> > /Users/jhag/projects/kvitt/infra/secrets/ha_known_hosts; the owner installs the public key (H-6); test the tar-stream variant first, fall back to plain copy if the add-on refuses authorized_keys options, and record the variant.
  9. rclone: after H-7, write /Users/jhag/projects/kvitt/infra/secrets/rclone.conf with [gdrive], type = drive, scope = drive, the token; copy to the Pi.
  10. Deploy: infra/deploy.sh <tag>; on the Pi docker compose ps shows all four services healthy.
  11. Router: the owner sets the forward (H-5); then check https://icehack.asuscomm.com:8443/health from the phone on mobile data (H-8).
  12. Backup: docker compose exec backup /backup.sh on the Pi; rclone lsf shows the new files in the Drive folder; local files are gone afterwards (D-002).
  13. Verify Caddy logs contain no Authorization header (one authenticated request, then grep docker compose logs caddy).
  14. Run the gate (§7). Commit on agent-m2c-pi (never a secret), git rebase main, rerun the gate. Never merge, push or remove the worktree.

7. Gate

From SPEC §11.2, verbatim: "docker compose -f infra/compose.yaml config succeeds; shellcheck infra/**/*.sh clean if shellcheck is installed; on the Pi: cert-sync fetched a valid certificate, https://icehack.asuscomm.com:8443/health answers from the phone on mobile data; one backup file appears in the Drive folder". Green means: config exits 0; shellcheck clean (or "not installed" stated); docker compose exec cert-sync shows /certs/fullchain.pem with a not-after in the future and openssl s_client -connect icehack.asuscomm.com:8443 (from the Mac) presents that certificate; the phone on mobile data gets "status":"ok" (H-8); rclone lsf lists the new kvitt-*.sqlite.gz and pictures-*.tar.gz in folder 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV.

8. HUMAN steps

From SPEC §11.2, verbatim: "Answer OQ-3; provide the example Gitea Actions workflow (and Gitea credentials if the registry needs them); install Docker on the Pi if missing; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; rclone authorize "drive"". Each with where it is needed, the exact command, and what blocks it (OQ-1 and OQ-4 are answered; OQ-3 is the only open question):

  • H-1, before step 7: answer OQ-3 (Kvitt Pi LAN address and SSH user; HA Pi LAN address, SSH port and user; certificate directory; which SSH add-on). Blocks H-3, H-5, H-6 and steps 7 to 13.
  • H-2, step 6: provide the example Gitea Actions workflow, and Gitea credentials if push or pull needs them. Not blocked by an open question; blocks only the Gitea Actions part (manual build-images.sh works meanwhile, unless credentials are required).
  • H-3, step 7, on the Kvitt Pi (only if docker version fails there), using the method from docs.docker.com for Raspberry Pi OS, for example: curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh && sudo usermod -aG docker <ssh user>, then log out and in. Blocked by OQ-3.
  • H-4, step 7, on the Kvitt Pi (sudo; derived from §10.3, not a separate §11.2 step): add {"insecure-registries": ["gitea.lan"]} to /etc/docker/daemon.json and run sudo systemctl restart docker. Blocked by OQ-3.
  • H-5, step 11, in the ASUS router web interface: WAN, Virtual Server / Port Forwarding: add a TCP rule, external port 8443, internal IP = the Kvitt Pi's LAN address, internal port 8443; Apply. Blocked by OQ-3.
  • H-6, step 8, in Home Assistant: Settings, Add-ons, , Configuration: add the one line from infra/secrets/ha_ssh_key.pub (with the restrict,command=… prefix the agent gives) to authorized_keys; Save; restart the add-on. Blocked by OQ-3.
  • H-7, step 9, on the owner's Mac: rclone authorize "drive", sign in with the owner's Google account in the browser, and leave the terminal output for the agent (the agent reads the token from the terminal session or the owner pastes it into a file the agent names inside infra/secrets/). Not blocked (OQ-4 answered). Needs rclone installed on the Mac.
  • H-8, step 11 (needed by the gate): on the phone with Wi-Fi off, open https://icehack.asuscomm.com:8443/health in Chrome; answer yes/no: the page shows "status":"ok" without a certificate warning. Blocked by H-5.
  • Prerequisite not listed in §11.2 (open point a): key-based SSH login from the Mac to the Kvitt Pi, for example ssh-copy-id <ssh user>@<Kvitt Pi address> on the owner's Mac. Blocked by OQ-3.

9. Out of scope

  • The development stack infra/dev/** (M2c-dev; do not break it).
  • Firebase project and app creation (done in M2c-dev); only copying the key to the Pi is here.
  • Release keystore, signed release, publishing the first real APK, pairing both phones, restore drill, memory check (M10).
  • Server or Android code changes; the Splitwise import (M11).
  • Changing the Home Assistant Pi beyond adding the one authorized_keys line; touching certbot there.

10. Report

  • Branch agent-m2c-pi, head commit hash after rebasing on main.
  • Gate output lines: config exit status, shellcheck result, certificate not-after and issuer, phone health result (H-8), rclone lsf lines (file names only).
  • Versions chosen (images with digests, packages, Docker install method) with sources.
  • The authorized_keys variant the HA add-on supports.
  • Image tag deployed; docker compose ps summary.
  • Paths of secrets written (never contents), modes set on the Pi.
  • How open points (a) to (c) were resolved.
  • Numbering used or TODO(spec) occurrences.
  • HUMAN steps done and outstanding, worded for relay.
  • Contract requests, open questions, spec gaps (with section numbers).

11. Numbering

Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use TODO(spec) if none was given.