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>
This commit is contained in:
2026-10-17 21:18:36 +02:00
parent 5e1be38364
commit c885aba313
13 changed files with 523 additions and 0 deletions
+31
View File
@@ -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://<Mac LAN address>:<KVITT_DEV_PORT>.
# 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:
+20
View File
@@ -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-<UTC stamp>.sqlite.gz and pictures-<stamp>.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" ]]
+42
View File
@@ -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
+86
View File
@@ -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
+21
View File
@@ -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"
+39
View File
@@ -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}"
}
+8
View File
@@ -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 "$@"
+44
View File
@@ -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://<Mac LAN address>:<port> 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