diff --git a/.gitignore b/.gitignore index fdc98ff..932b31c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ infra/secrets/ *service-account*.json google-services.json *.apk +# Development stack on the Mac (infra/dev): local env, bind-mounted data and local backups +infra/dev/.env +infra/dev/data/ +infra/dev/backups/ +*.sqlite.gz +*.sqlite-wal +*.sqlite-shm diff --git a/infra/.shellcheckrc b/infra/.shellcheckrc new file mode 100644 index 0000000..788f62c --- /dev/null +++ b/infra/.shellcheckrc @@ -0,0 +1,3 @@ +# shellcheck settings for every script under infra/ (found by searching upward from each script). +external-sources=true +source-path=SCRIPTDIR diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..8687867 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,143 @@ +# 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://:`. 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 `. 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 # 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://:8080` and the printed code (AC-2.7). Check reachability first by opening +`http://: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-.sqlite.gz`, `pictures-.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-.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-`, 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//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 ...`. +Copying the key to the Pi is M2c-pi (§10.7 step 7). diff --git a/infra/backup/Dockerfile b/infra/backup/Dockerfile new file mode 100644 index 0000000..e4720ef --- /dev/null +++ b/infra/backup/Dockerfile @@ -0,0 +1,20 @@ +# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh run by crond. +# Versions resolved 2026-10-17: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below); +# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e. +FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 + +RUN apk add --no-cache \ + "sqlite~3.53" \ + "rclone~1.74" \ + "tar~1.35" \ + gzip \ + tzdata + +COPY backup.sh /backup.sh +RUN chmod 0755 /backup.sh \ + && echo '30 3 * * * /backup.sh' > /etc/crontabs/root + +ENV TZ=Europe/Stockholm +# Daily at 03:30 Europe/Stockholm. The development stack runs it on demand instead +# (`docker compose run --rm backup /backup.sh`, see infra/README.md). +CMD ["crond", "-f", "-l", "8"] diff --git a/infra/backup/backup.sh b/infra/backup/backup.sh new file mode 100755 index 0000000..9469c3b --- /dev/null +++ b/infra/backup/backup.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Kvitt backup job (SPEC §10.9). Runs inside the backup image with /data (server data) and /backups mounted. +# 1. online, consistent snapshot of /data/kvitt.sqlite with sqlite3 .backup +# 2. PRAGMA integrity_check on the snapshot must print ok, else abort +# 3. gzip -9 the snapshot and tar the pictures, both with the same UTC stamp +# 4. Drive upload: skipped when RCLONE_FOLDER_ID is empty (the development stack, D-001); +# the upload itself and the D-002 local deletion are built by M2c-pi +# 5. write /backups/last-success with the stamp +# Logs carry file names and sizes only, never database content (SPEC §8.7). +set -eu + +DATA_DIR=${KVITT_DATA_DIR:-/data} +BACKUP_DIR=${BACKUP_DIR:-/backups} +DB="$DATA_DIR/kvitt.sqlite" +STAGING="$BACKUP_DIR/staging" + +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) backup: $*"; } +fail() { log "FAILED: $*"; rm -f "$STAGING/kvitt.sqlite"; exit 1; } + +[ -f "$DB" ] || fail "no database at $DB" +mkdir -p "$STAGING" +rm -f "$STAGING/kvitt.sqlite" + +# 1. Snapshot +sqlite3 -batch "$DB" ".backup '$STAGING/kvitt.sqlite'" || fail "sqlite3 .backup returned an error" + +# 2. Integrity check +result=$(sqlite3 -batch -noheader -list "$STAGING/kvitt.sqlite" "PRAGMA integrity_check") || fail "integrity_check could not run" +[ "$result" = "ok" ] || fail "integrity_check did not print ok" + +# 3. Compress with one UTC stamp +stamp=$(date -u +%Y%m%dT%H%M%SZ) +db_out="$BACKUP_DIR/kvitt-$stamp.sqlite.gz" +pics_out="$BACKUP_DIR/pictures-$stamp.tar.gz" +gzip -9 -c "$STAGING/kvitt.sqlite" > "$db_out.tmp" || fail "gzip failed" +mv "$db_out.tmp" "$db_out" +rm -f "$STAGING/kvitt.sqlite" +if [ -d "$DATA_DIR/pictures" ]; then + tar -czf "$pics_out.tmp" -C "$DATA_DIR" pictures || fail "tar of pictures failed" +else + log "no pictures directory yet; writing an empty pictures archive" + tar -czf "$pics_out.tmp" -T /dev/null || fail "tar of empty pictures archive failed" +fi +mv "$pics_out.tmp" "$pics_out" +log "wrote $(basename "$db_out") ($(wc -c < "$db_out") bytes) and $(basename "$pics_out") ($(wc -c < "$pics_out") bytes)" + +# 4. Drive upload +if [ -z "${RCLONE_FOLDER_ID:-}" ]; then + log "RCLONE_FOLDER_ID is empty: Drive upload skipped, local files kept" +else + # TODO(M2c-pi): rclone copy --immutable to "${RCLONE_REMOTE:-gdrive:}" --drive-root-folder-id, confirm with + # rclone lsf, then delete the local files (D-002). Not part of the development stack (D-001). + fail "RCLONE_FOLDER_ID is set but the Drive upload is not built yet (M2c-pi); local files kept" +fi + +# 5. Success marker +echo "$stamp" > "$BACKUP_DIR/last-success.tmp" +mv "$BACKUP_DIR/last-success.tmp" "$BACKUP_DIR/last-success" +log "ok $stamp" diff --git a/infra/dev/.env.example b/infra/dev/.env.example new file mode 100644 index 0000000..0bff7d5 --- /dev/null +++ b/infra/dev/.env.example @@ -0,0 +1,31 @@ +# Kvitt development stack settings (infra/dev/compose.yaml). No secret values here, ever. +# Copy to infra/dev/.env (git-ignored) to change anything; the scripts use .env when it exists, +# otherwise this file. Paths are relative to infra/dev/. + +# Images built by infra/dev/build.sh. +KVITT_SERVER_IMAGE=kvitt-server:dev +KVITT_BACKUP_IMAGE=kvitt-backup:dev + +# Port published on the Mac (all interfaces, so the phone on the home Wi-Fi can reach it). +KVITT_DEV_PORT=8080 + +# URL used in invite links. Leave empty: up.sh sets http://:. +# Set it only to pin an address, for example http://192.168.1.20:8080 (private LAN address, plain http). +KVITT_PUBLIC_URL= + +# Firebase project for FCM HTTP v1 (SPEC §10.4, §10.7). +KVITT_FCM_PROJECT_ID=kvitt-1337 + +# tracing filter (SPEC §10.4). +RUST_LOG=info + +# Bind-mounted folders (git-ignored). Deleting data/ resets the dev server. +KVITT_DEV_DATA_DIR=./data +KVITT_DEV_BACKUP_DIR=./backups + +# Folder holding fcm-service-account.json (git-ignored). Default: infra/secrets of this checkout. +# In a worktree without copied secrets, point it at the main checkout's infra/secrets. +KVITT_SECRETS_DIR=../secrets + +# rclone remote name; unused on the dev stack because RCLONE_FOLDER_ID is always empty here. +RCLONE_REMOTE=gdrive: diff --git a/infra/dev/backup.sh b/infra/dev/backup.sh new file mode 100755 index 0000000..780cd15 --- /dev/null +++ b/infra/dev/backup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Runs the backup job once against the development data (SPEC §10.9 with RCLONE_FOLDER_ID empty: no Drive upload). +# Writes kvitt-.sqlite.gz and pictures-.tar.gz into the backups folder (default infra/dev/backups/) +# and checks the newest snapshot with PRAGMA integrity_check, using the backup image's own sqlite3. +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +backup_dir="$(dev_path "$(env_value KVITT_DEV_BACKUP_DIR)")" +mkdir -p "$backup_dir" + +compose --profile backup run --rm -T backup /backup.sh + +latest="$(find "$backup_dir" -maxdepth 1 -name 'kvitt-*.sqlite.gz' | sort | tail -n 1)" +[[ -n "$latest" ]] || { echo "backup.sh: no snapshot found in $backup_dir" >&2; exit 1; } +name="$(basename "$latest")" +result="$(compose --profile backup run --rm -T --entrypoint sh backup -c \ + "gunzip -c '/backups/$name' > /tmp/check.sqlite && sqlite3 -batch -noheader -list /tmp/check.sqlite 'PRAGMA integrity_check'")" +echo "$name: integrity_check $result" +[[ "$result" == "ok" ]] diff --git a/infra/dev/build.sh b/infra/dev/build.sh new file mode 100755 index 0000000..03212a1 --- /dev/null +++ b/infra/dev/build.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Builds the development images on this Mac (native linux/arm64, nothing pushed): +# kvitt-server:dev from server/Dockerfile (milestone M2a) +# kvitt-backup:dev from infra/backup/Dockerfile +# Usage: infra/dev/build.sh [server|backup|all] (default: all) +# Refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md: never build images during a Gradle build). +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +what="${1:-all}" +lock=/tmp/kvitt-gradle.lock +if [[ -d "$lock" ]] && pid="$(cat "$lock/pid" 2>/dev/null)" && [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + echo "build.sh: a Gradle build is running (pid $pid, $lock); try again when it is done." >&2 + exit 1 +fi + +server_image="$(env_value KVITT_SERVER_IMAGE)"; server_image="${server_image:-kvitt-server:dev}" +backup_image="$(env_value KVITT_BACKUP_IMAGE)"; backup_image="${backup_image:-kvitt-backup:dev}" + +build_server() { + local dockerfile="$repo_root/server/Dockerfile" + local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root/server}" + if [[ ! -f "$dockerfile" ]]; then + echo "build.sh: $dockerfile does not exist yet (it comes with milestone M2a)." >&2 + return 1 + fi + echo "Building $server_image from server/Dockerfile" + docker build --platform linux/arm64 -f "$dockerfile" -t "$server_image" "$context" +} + +build_backup() { + echo "Building $backup_image from infra/backup/Dockerfile" + docker build --platform linux/arm64 -f "$repo_root/infra/backup/Dockerfile" -t "$backup_image" "$repo_root/infra/backup" +} + +case "$what" in + server) build_server ;; + backup) build_backup ;; + all) build_backup; build_server ;; + *) echo "usage: $0 [server|backup|all]" >&2; exit 2 ;; +esac diff --git a/infra/dev/compose.yaml b/infra/dev/compose.yaml new file mode 100644 index 0000000..10b19bc --- /dev/null +++ b/infra/dev/compose.yaml @@ -0,0 +1,86 @@ +# Kvitt development stack on the owner's Mac (SPEC §10 "Development first", D-001). +# Not the Pi stack: that is infra/compose.yaml, built by M2c-pi. +# +# No Caddy and no TLS here: debug builds accept http:// to private LAN addresses (AC-2.8), so the +# server publishes its own port on the Mac. Release builds never talk to this stack. +# +# Use the scripts next to this file (see infra/README.md): +# infra/dev/build.sh build kvitt-server:dev (from server/Dockerfile) and kvitt-backup:dev +# infra/dev/up.sh start the server, wait for /health, print the LAN URL +# infra/dev/logs.sh follow the logs +# infra/dev/backup.sh run the backup job once (local snapshot, no Drive upload) +# Relative paths below resolve against this directory (infra/dev/). + +name: kvitt-dev + +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + server: + image: ${KVITT_SERVER_IMAGE:-kvitt-server:dev} + command: ["serve"] + restart: unless-stopped + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + mem_limit: 160m + ports: + # The only published port of the dev stack; reachable from the phone on the home Wi-Fi. + - "${KVITT_DEV_PORT:-8080}:8080" + environment: + TZ: Europe/Stockholm + KVITT_DATA_DIR: /data + KVITT_BIND: 0.0.0.0:8080 + # Invite links and the landing page point here; up.sh fills it from the Mac's LAN address when empty. + KVITT_PUBLIC_URL: ${KVITT_PUBLIC_URL:-http://localhost:8080} + KVITT_FCM_CREDENTIALS: /secrets/fcm-service-account.json + KVITT_FCM_PROJECT_ID: ${KVITT_FCM_PROJECT_ID:-kvitt-1337} + RUST_LOG: ${RUST_LOG:-info} + volumes: + # SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored). + - ${KVITT_DEV_DATA_DIR:-./data}:/data + # The secrets folder, read-only, so the server finds /secrets/fcm-service-account.json (git-ignored). + # The folder is mounted rather than the file: a missing source file would make Docker create a directory + # in its place, while a missing key must simply leave push disabled with a start-up warning (§10.4). + - ${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro + healthcheck: + test: ["CMD", "kvitt-server", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + logging: *logging + networks: [kvitt] + + backup: + image: ${KVITT_BACKUP_IMAGE:-kvitt-backup:dev} + # On demand only: `docker compose run --rm backup /backup.sh` (infra/dev/backup.sh). `up` skips it. + profiles: [backup] + command: ["/backup.sh"] + restart: "no" + # Same uid as the server, so it can open the WAL database files the server owns. + user: "10001:10001" + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + mem_limit: 96m + environment: + TZ: Europe/Stockholm + # Empty on the dev stack: the Drive upload is skipped and the local files are kept (§10.9, D-001). + RCLONE_FOLDER_ID: "" + RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:} + volumes: + - ${KVITT_DEV_DATA_DIR:-./data}:/data + - ${KVITT_DEV_BACKUP_DIR:-./backups}:/backups + logging: *logging + networks: [kvitt] + +networks: + kvitt: + driver: bridge diff --git a/infra/dev/copy-google-services.sh b/infra/dev/copy-google-services.sh new file mode 100755 index 0000000..922bfd8 --- /dev/null +++ b/infra/dev/copy-google-services.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copies the Firebase config (both Android clients, se.hagstedt.kvitt and se.hagstedt.kvitt.debug) from +# infra/secrets/google-services.json to android/app/google-services.json in this checkout. Both are git-ignored. +# Usage: infra/dev/copy-google-services.sh [source checkout] (default: this checkout; in a worktree, pass the main checkout) +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +src_root="${1:-$repo_root}" +src="$src_root/infra/secrets/google-services.json" +dst="$repo_root/android/app/google-services.json" + +[[ -f "$src" ]] || { echo "copy-google-services.sh: $src not found (created by the M2c-dev Firebase setup)." >&2; exit 1; } +git -C "$repo_root" check-ignore -q "android/app/google-services.json" || { + echo "copy-google-services.sh: android/app/google-services.json is not git-ignored; refusing to copy." >&2 + exit 1 +} +mkdir -p "$(dirname "$dst")" +cp "$src" "$dst" +chmod 600 "$dst" +echo "Copied google-services.json to $dst" diff --git a/infra/dev/lib.sh b/infra/dev/lib.sh new file mode 100644 index 0000000..add1e2e --- /dev/null +++ b/infra/dev/lib.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Shared helpers for the infra/dev scripts. Sourced, not run. +# shellcheck disable=SC2034 # variables are used by the scripts that source this file + +dev_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$dev_dir/../.." && pwd)" + +# infra/dev/.env (git-ignored) when it exists, otherwise the committed example. +if [[ -f "$dev_dir/.env" ]]; then env_file="$dev_dir/.env"; else env_file="$dev_dir/.env.example"; fi + +# Value of KEY in the env file (empty when unset or blank). +env_value() { + sed -n "s/^$1=//p" "$env_file" | tail -n 1 +} + +compose() { + docker compose -f "$dev_dir/compose.yaml" --env-file "$env_file" "$@" +} + +# Resolve a path from the env file the way compose does: relative to infra/dev/. +dev_path() { + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s\n' "$dev_dir/$1" ;; + esac +} + +# The Mac's address on the interface that carries the default route (the home Wi-Fi). +lan_address() { + local iface + iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')" + [[ -n "$iface" ]] && ipconfig getifaddr "$iface" 2>/dev/null +} + +dev_port() { + local p + p="$(env_value KVITT_DEV_PORT)" + printf '%s\n' "${p:-8080}" +} diff --git a/infra/dev/logs.sh b/infra/dev/logs.sh new file mode 100755 index 0000000..7e1ce23 --- /dev/null +++ b/infra/dev/logs.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Follows the development stack's logs. Extra arguments go to `docker compose logs` +# (for example: infra/dev/logs.sh --tail 100 server). +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +compose logs -f "$@" diff --git a/infra/dev/up.sh b/infra/dev/up.sh new file mode 100755 index 0000000..ebae718 --- /dev/null +++ b/infra/dev/up.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Starts the development server (infra/dev/compose.yaml), waits for GET /health and prints the URLs. +# KVITT_PUBLIC_URL defaults to http://: when the env file leaves it empty. +# Usage: infra/dev/up.sh Stop with: infra/dev/up.sh --down +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +if [[ "${1:-}" == "--down" ]]; then + compose down + exit 0 +fi + +port="$(dev_port)" +lan="$(lan_address || true)" + +public_url="$(env_value KVITT_PUBLIC_URL)" +if [[ -z "$public_url" ]]; then + if [[ -n "$lan" ]]; then public_url="http://$lan:$port"; else public_url="http://localhost:$port"; fi +fi +export KVITT_PUBLIC_URL="$public_url" + +data_dir="$(dev_path "$(env_value KVITT_DEV_DATA_DIR)")" +secrets_dir="$(dev_path "$(env_value KVITT_SECRETS_DIR)")" +mkdir -p "$data_dir" +if [[ ! -f "$secrets_dir/fcm-service-account.json" ]]; then + echo "up.sh: no $secrets_dir/fcm-service-account.json; the server starts with push disabled." >&2 +fi + +compose up -d server + +echo "Waiting for http://localhost:$port/health ..." +for _ in $(seq 1 30); do + if body="$(curl -fsS "http://localhost:$port/health" 2>/dev/null)"; then + echo "health: $body" + echo "Mac: http://localhost:$port" + [[ -n "$lan" ]] && echo "Phone: http://$lan:$port (home Wi-Fi)" + echo "Invites: $public_url" + exit 0 + fi + sleep 1 +done +echo "up.sh: /health did not answer within 30 s; see infra/dev/logs.sh" >&2 +exit 1