Files
kvitt/infra/README.md
T
jacobh2 c885aba313 M2c-dev: development compose stack, backup job, Firebase setup docs
infra/dev: compose stack on the Mac (server kvitt-server:dev, on-demand backup),
.env.example, build/up/logs/backup/copy-google-services scripts. No Caddy, no TLS
(debug builds accept http to private LAN addresses, AC-2.8). Bind-mounted data.
infra/backup: alpine 3.24.2 image and backup.sh (SPEC 10.9; Drive upload skipped
when RCLONE_FOLDER_ID is empty). infra/README.md for the dev stack and Firebase.

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

144 lines
7.7 KiB
Markdown

# infra: Kvitt infrastructure
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001) and the
**Firebase setup** (SPEC §10.7). The Pi stack (`infra/compose.yaml`, Caddy with the Let's Encrypt certificate,
cert-sync, the Gitea registry, Drive backups, deploy and APK publishing) is built by M2c-pi, which adds its own
sections here.
## Layout
| Path | What |
|---|---|
| `dev/compose.yaml` | Dev stack: `server` (image `kvitt-server:dev`) and the on-demand `backup` job |
| `dev/.env.example` | Dev settings, no secret values; copy to `dev/.env` (git-ignored) to override |
| `dev/build.sh` | Builds `kvitt-server:dev` from `server/Dockerfile` and `kvitt-backup:dev` from `backup/Dockerfile` |
| `dev/up.sh` | Starts the server, waits for `/health`, prints the Mac and phone URLs; `--down` stops the stack |
| `dev/logs.sh` | Follows the logs (`dev/logs.sh --tail 100 server`) |
| `dev/backup.sh` | Runs the backup job once and checks the snapshot with `PRAGMA integrity_check` |
| `dev/copy-google-services.sh` | Copies `secrets/google-services.json` to `android/app/google-services.json` |
| `dev/lib.sh` | Helpers sourced by the scripts |
| `backup/Dockerfile`, `backup/backup.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack) |
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`. Never committed |
All commands below run from the repository root.
## Development stack
**Choices.** No Caddy and no TLS: debug builds accept `http://` to private LAN addresses (AC-2.8), so the server
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9
(uid 10001, read-only root, no capabilities, `no-new-privileges`), the 160 MiB limit, `json-file` logging
(10 MB x 3) and `TZ=Europe/Stockholm`. Data lives in the bind-mounted folder `infra/dev/data/` (git-ignored) so it
is easy to inspect and reset. The secrets folder is mounted read-only at `/secrets`; if
`fcm-service-account.json` is missing the server runs with push disabled (§10.4).
On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the files work with Docker Desktop as well.
### Build
```bash
infra/dev/build.sh # both images
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile (milestone M2a)
infra/dev/build.sh backup # only kvitt-backup:dev
```
The server build is `docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server`
(override the context with `KVITT_SERVER_BUILD_CONTEXT`). The script refuses to run while a Gradle build holds
`/tmp/kvitt-gradle.lock` (CLAUDE.md).
### Start, stop, logs
```bash
infra/dev/up.sh # start; prints health, the Mac URL and the phone URL
infra/dev/logs.sh # follow logs (Ctrl-C to stop following)
infra/dev/up.sh --down # stop and remove the containers (data stays in infra/dev/data/)
```
`KVITT_PUBLIC_URL` (used in invite links) is left empty in `.env.example`; `up.sh` sets it to
`http://<Mac LAN address>:<port>`. Pin it in `infra/dev/.env` if the Mac's address changes often.
### Finding the Mac's LAN address
`up.sh` prints it. By hand: `ipconfig getifaddr en0` (Wi-Fi on most Macs), or the interface of the default route:
`route -n get default | grep interface`, then `ipconfig getifaddr <interface>`. It must be a private address
(`192.168.*`, `10.*`, `172.16.*` to `172.31.*`) for a debug build to accept `http://` (AC-2.8).
The first time a phone connects, macOS may ask whether the container runtime (Podman's `gvproxy`, or Docker)
may accept incoming network connections: answer Allow.
### Bootstrap and pairing a debug build
The `bootstrap` and `pair-device` subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.
```bash
C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example" # or --env-file infra/dev/.env
$C exec server kvitt-server bootstrap # first admin invite: code, link, expiry
$C exec server kvitt-server pair-device --user <user_id> # extra device invite for an existing user
$C run --rm server migrate # when the server is stopped (entrypoint is kvitt-server)
```
On the phone (home Wi-Fi, debug build `se.hagstedt.kvitt.debug`): open the pairing screen, enter the server URL
`http://<Mac LAN address>:8080` and the printed code (AC-2.7). Check reachability first by opening
`http://<Mac LAN address>:8080/health` in Chrome on the phone: it shows `"status":"ok"`.
### Backup (local, no Drive upload)
```bash
infra/dev/backup.sh
```
Runs `infra/backup/backup.sh` once in the `backup` image (compose profile `backup`, so `up` never starts it) with
`RCLONE_FOLDER_ID` empty: `.backup` snapshot, `PRAGMA integrity_check` must print `ok`, then
`infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz`, `pictures-<stamp>.tar.gz` and `last-success`; the Drive upload
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. To check by hand: `gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`.
### Resetting the dev data
```bash
infra/dev/up.sh --down
rm -rf infra/dev/data # the dev database, pictures and APKs; never the phones' data
infra/dev/up.sh # fresh database; run bootstrap again
```
Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on
the phone, or reinstall the debug build).
### Worktrees
Agents work in `.claude/worktrees/agent-<id>`, which has no `infra/secrets/` unless copied (CLAUDE.md). Either copy
it from the main checkout, or set `KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets` in `infra/dev/.env`.
`infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt` copies the Firebase config from the main checkout
into the worktree's `android/app/`.
## Firebase (SPEC §10.7)
Project `kvitt-1337` (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is
created by this setup.
| Resource | Name |
|---|---|
| APIs enabled | `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com`, `iam.googleapis.com` |
| Android app | "Kvitt", package `se.hagstedt.kvitt` |
| Android app | "Kvitt debug", package `se.hagstedt.kvitt.debug` |
| Service account | `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` ("Kvitt FCM sender"), role `roles/firebasecloudmessaging.admin` (the narrowest predefined role holding `cloudmessaging.messages.create`) |
| Key | `infra/secrets/fcm-service-account.json` (git-ignored, mode 600) |
| Config | `infra/secrets/google-services.json`, one file with both Android clients (git-ignored) |
Put the config where Gradle reads it: `infra/dev/copy-google-services.sh`.
The setup used only `gcloud` and the Firebase Management REST API. To repeat or inspect it:
```bash
T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
curl -sS "${H[@]}" "$API/androidApps" # list the apps (ids, packages)
curl -sS "${H[@]}" "$API/androidApps/<appId>/config" # configFileContents is base64 google-services.json
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337
```
Rotating the key: create a new one with
`gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337`,
restart the server, then delete the old key id with `gcloud iam service-accounts keys delete <key id> ...`.
Copying the key to the Pi is M2c-pi (§10.7 step 7).