M2c-pi: Pi bundle (infra/pi), cert-sync, Drive backup upload, Gitea workflow

The owner copies infra/pi/ to the Kvitt Pi and runs it there (D-100); nothing
on the Mac connects to the Pis. compose stack of SPEC §10.2 with images
${KVITT_REGISTRY}/kvitt-{server,cert-sync,backup}, Caddyfile §10.5.2 verbatim,
.env.example with the OQ-3 values as CHANGE_ME placeholders, install.sh,
deploy.sh (backup before every deploy), bootstrap.sh, backup-now.sh,
install-apk.sh. cert-sync image and sync.sh (§10.5.1, tar stream for both
authorized_keys variants); backup.sh uploads with rclone copy --immutable and
deletes local files once both are confirmed on Drive (D-002); both sidecars
schedule with supercronic because busybox crond runs no jobs as non-root;
backup runs as uid 10001. .gitea/workflows/build-images.yaml from the owner's
example (not yet run); infra/build-images.sh and infra/publish-apk.sh (R8
mapping kept, version per §10.6) on the Mac; README checklist and RESTORE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 21:46:44 +02:00
parent bfc3acbbe0
commit 2476667510
21 changed files with 1409 additions and 24 deletions
+37
View File
@@ -0,0 +1,37 @@
# Kvitt Pi settings (SPEC §10.2). Copy to .env in this folder, replace every CHANGE_ME, then run ./install.sh,
# which copies it to /opt/kvitt/.env (mode 0600). install.sh refuses a .env that still contains CHANGE_ME.
# No secret files here: those go to /opt/kvitt/secrets/ (infra/README.md "Pi: what the owner does").
# --- Read by compose.yaml -----------------------------------------------------------------------------------------
# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup.
KVITT_REGISTRY=gitea.lan/jacobh2
# Image tag to run. deploy.sh sets it; first install: the newest version tag the Gitea workflow pushed (vMAJOR.MINOR.PATCH),
# or the short git SHA that infra/build-images.sh printed.
KVITT_TAG=CHANGE_ME_TAG
# LAN address (or host name) of the Home Assistant Pi that holds the Let's Encrypt certificate (OQ-3).
HA_HOST=CHANGE_ME_HA_HOST
# SSH port of the Home Assistant SSH add-on (OQ-3; the "Advanced SSH & Web Terminal" and "Terminal & SSH" add-ons default to 22).
HA_PORT=22
# SSH user on the Home Assistant Pi (OQ-3; the Home Assistant SSH add-ons log in as root, or the user set in the add-on).
HA_USER=CHANGE_ME_HA_USER
# Directory holding fullchain.pem and privkey.pem on the Home Assistant Pi (OQ-3; /ssl on Home Assistant OS).
HA_CERT_DIR=CHANGE_ME_HA_CERT_DIR
# rclone remote of the Drive backups, as named in rclone.conf.
RCLONE_REMOTE=gdrive:
# Google Drive folder id the backups go to (OQ-4, answered).
RCLONE_FOLDER_ID=1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV
# --- Read by install.sh only (never by the containers) ------------------------------------------------------------
# Gitea user that may pull the images; leave empty when the Gitea packages are public (anonymous pull).
KVITT_REGISTRY_USER=
# Gitea access token of that user with read:package scope; leave empty for anonymous pull. Never commit a filled .env.
KVITT_REGISTRY_TOKEN=
# --- Informational: used in the owner's commands on the Mac (scp), read by nothing on the Pi --------------------
# LAN address of the Kvitt Pi itself (OQ-3).
KVITT_PI_HOST=CHANGE_ME_KVITT_PI_HOST
# SSH user on the Kvitt Pi (OQ-3).
KVITT_PI_SSH_USER=CHANGE_ME_KVITT_PI_SSH_USER
+5
View File
@@ -0,0 +1,5 @@
# Filled on the Mac or the Pi, never committed: host addresses and registry credentials.
.env
# publish-apk.sh output (APKs, latest.json, R8 mappings) and secrets staged for the copy to the Pi.
out/
secrets/
+16
View File
@@ -0,0 +1,16 @@
{
admin 0.0.0.0:2019
auto_https off
}
icehack.asuscomm.com:8443 {
tls /certs/fullchain.pem /certs/privkey.pem
request_body {
max_size 6MB
}
reverse_proxy server:8080
log {
output stdout
format json
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Run the backup once on the Kvitt Pi (SPEC §10.9) and wait for it: /opt/kvitt/backup-now.sh
# Succeeds only when backup.sh succeeded and /backups/last-success changed: the snapshot passed integrity_check,
# both files are confirmed on Drive and their local copies are deleted (D-002). Lists the newest files on Drive.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
. "$here/lib.sh"
before=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true)
compose exec -T backup /backup.sh || die "backup.sh failed (lines above)"
after=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true)
[[ -n "$after" && "$after" != "$before" ]] || die "last-success did not change (was '${before:-none}', is '${after:-none}')"
say "last-success: $after"
remote=$(env_value RCLONE_REMOTE); folder=$(env_value RCLONE_FOLDER_ID)
say "Newest files in the Drive folder:"
# shellcheck disable=SC2016 # the script runs inside the backup container, with its own \$1 and \$2
compose exec -T backup sh -c 'c=$(mktemp); cp /config/rclone/rclone.conf "$c"; RCLONE_CONFIG=$c rclone lsf "$1" --drive-root-folder-id "$2" --files-only --log-level ERROR; s=$?; rm -f "$c"; exit $s' \
sh "${remote:-gdrive:}" "$folder" | sort | tail -n 4
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# First admin invite on the Kvitt Pi (SPEC FR-1, §10.4). Run ON the Pi once, after install.sh: /opt/kvitt/bootstrap.sh
# Prints the invite code, link and expiry. Exit 2 from the server means the database is already initialised.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
. "$here/lib.sh"
set +e
compose exec -T server kvitt-server bootstrap
status=$?
set -e
case "$status" in
0) say "Open the link on the owner's phone (or enter the code on the pairing screen)." ;;
2) say "Already initialised: no new invite. Extra devices: docker compose exec server kvitt-server pair-device --user <user_id>" ;;
*) die "bootstrap exited $status (docker compose logs server)" ;;
esac
+137
View File
@@ -0,0 +1,137 @@
# Kvitt production stack on the Kvitt Pi (SPEC §10.2). Deployed by install.sh to /opt/kvitt/compose.yaml together
# with /opt/kvitt/Caddyfile and /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt.
# Only caddy publishes a port (8443); the server, Caddy's admin API and the sidecars are reachable only on the
# internal network `kvitt` (SPEC §8.9).
# Image names: ${KVITT_REGISTRY}/kvitt-<name>, the names the Gitea Actions workflow pushes (D-104).
name: kvitt
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
caddy:
# Official image, pinned by version and multi-arch index digest (resolved 2026-10-18, infra/README.md).
image: docker.io/library/caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 64m
ports:
- "8443:8443"
environment:
TZ: Europe/Stockholm
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- certs:/certs:ro
- caddy_data:/data
depends_on:
cert-sync:
condition: service_healthy
server:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:2019/config/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging: *logging
networks: [kvitt]
server:
image: ${KVITT_REGISTRY:?set KVITT_REGISTRY in .env}/kvitt-server:${KVITT_TAG:?set KVITT_TAG in .env}
command: ["serve"]
restart: unless-stopped
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 160m
environment:
TZ: Europe/Stockholm
# The remaining server settings keep their SPEC §10.4 defaults (KVITT_PUBLIC_URL
# https://icehack.asuscomm.com:8443, KVITT_TRUSTED_PROXY caddy, KVITT_FCM_PROJECT_ID kvitt-1337, ...).
KVITT_DATA_DIR: /data
volumes:
- data:/data
- /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro
# Empty except during the one-off Splitwise import (SPEC §10.8, D-025).
- /opt/kvitt/import:/import:ro
healthcheck:
test: ["CMD", "kvitt-server", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging: *logging
networks: [kvitt]
cert-sync:
image: ${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG}
restart: unless-stopped
user: "1000:1000"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 32m
environment:
TZ: Europe/Stockholm
HA_HOST: ${HA_HOST:?set HA_HOST in .env}
HA_PORT: ${HA_PORT:-22}
HA_USER: ${HA_USER:?set HA_USER in .env}
HA_CERT_DIR: ${HA_CERT_DIR:?set HA_CERT_DIR in .env}
volumes:
- certs:/certs
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro
- /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro
healthcheck:
test: ["CMD-SHELL", "test -s /certs/fullchain.pem && test -s /certs/privkey.pem"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
logging: *logging
networks: [kvitt]
backup:
image: ${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}
restart: unless-stopped
# uid 10001 like the server, so it can open the WAL database files the server owns; rclone.conf on the Pi is
# therefore owned by 10001 (infra/README.md "Backup uid", D-105).
user: "10001:10001"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 96m
environment:
TZ: Europe/Stockholm
RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:}
RCLONE_FOLDER_ID: ${RCLONE_FOLDER_ID:?set RCLONE_FOLDER_ID in .env}
volumes:
- data:/data
- backups:/backups
- /opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro
healthcheck:
test: ["CMD", "/healthcheck.sh"]
interval: 5m
timeout: 10s
retries: 1
start_period: 30s
logging: *logging
networks: [kvitt]
volumes:
data:
certs:
caddy_data:
backups:
networks:
kvitt:
driver: bridge
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Deploy an image tag on the Kvitt Pi (SPEC §10.3, §10.10). Run ON the Pi: /opt/kvitt/deploy.sh <tag>
# 1. backup first (backup-now.sh: Drive upload confirmed, last-success updated), because a tag may carry a
# migration and the Pi cannot tell beforehand (the rule "backup before a deploy with a migration" is applied
# to every deploy); skipped when no database exists yet, or with --skip-backup (rollback while Drive is down)
# 2. sets KVITT_TAG in /opt/kvitt/.env (the previous tag is kept in /opt/kvitt/previous-tag)
# 3. docker compose pull && docker compose up -d, waits for server to be healthy, prints GET /health
# Rollback after a failed migration: /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)"; if a migration had
# partially applied, restore the pre-deploy backup (infra/RESTORE.md).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
. "$here/lib.sh"
skip_backup=0
tag=""
for arg in "$@"; do
case "$arg" in
--skip-backup) skip_backup=1 ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
-*) die "unknown option $arg" ;;
*) [[ -z "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"; tag=$arg ;;
esac
done
[[ -n "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"
[[ "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || die "not a valid image tag: $tag"
[[ -f "$KVITT_DIR/.env" ]] || die "$KVITT_DIR/.env is missing: run install.sh first"
current=$(env_value KVITT_TAG)
say "Deploying $tag (running: ${current:-none})"
# 1. Backup before the deploy.
if (( skip_backup == 1 )); then
say "Backup skipped (--skip-backup)"
elif [[ "$(service_health server)" == none ]] || ! compose exec -T backup test -f /data/kvitt.sqlite 2>/dev/null; then
say "No running server with a database: no backup needed"
else
"$here/backup-now.sh" || die "the pre-deploy backup failed; nothing deployed (fix the backup, or use --skip-backup knowingly)"
fi
# 2. Tag.
[[ -n "$current" ]] && printf '%s\n' "$current" > "$KVITT_DIR/previous-tag"
env_set KVITT_TAG "$tag"
# 3. Pull and start.
if ! compose pull; then
[[ -n "$current" ]] && env_set KVITT_TAG "$current"
die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}"
fi
compose up -d --remove-orphans
if ! wait_healthy server 180; then
compose logs --tail 50 server || true
die "server is not healthy on $tag. Roll back: $KVITT_DIR/deploy.sh ${current:-<previous tag>} --skip-backup"
fi
compose ps
print_health || die "server is healthy but /health through Caddy failed (docker compose logs caddy cert-sync)"
say "Deployed $tag."
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Put the APKs that infra/publish-apk.sh made on the Mac into the server's data volume (SPEC §10.6).
# The owner copies infra/pi/out/apk/ from the Mac to /opt/kvitt/apk/ on the Pi, then runs ON the Pi:
# /opt/kvitt/install-apk.sh
# Copies every kvitt-<version_code>.apk from /opt/kvitt/apk into /data/apk, then latest.json last and atomically
# (temp file, rename), so the server never announces a file it does not have; keeps the newest 5 APKs in /data/apk.
# R8 mapping files (kvitt-<version_code>.mapping.txt) stay in /opt/kvitt/apk; the server never needs them.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
. "$here/lib.sh"
src="$KVITT_DIR/apk"
[[ -f "$src/latest.json" ]] || die "no $src/latest.json: copy infra/pi/out/apk/ from the Mac first (README, Publishing an APK)"
file_name=$(sed -n 's/.*"file_name": *"\([^"]*\)".*/\1/p' "$src/latest.json")
[[ "$file_name" =~ ^kvitt-[0-9]+\.apk$ ]] || die "latest.json has no valid file_name"
[[ -f "$src/$file_name" ]] || die "latest.json names $file_name, which is not in $src"
sha=$(sed -n 's/.*"sha256": *"\([0-9a-f]\{64\}\)".*/\1/p' "$src/latest.json")
[[ "$(sha256sum "$src/$file_name" | cut -d' ' -f1)" == "$sha" ]] || die "$file_name does not match the sha256 in latest.json"
# backup runs as uid 10001, the owner of /data, and has a shell; the server image has none.
# shellcheck disable=SC2016 # the script runs inside the backup container
compose run --rm --no-deps -T -v "$src:/incoming:ro" --entrypoint /bin/sh backup -c '
set -eu
mkdir -p /data/apk
for f in /incoming/kvitt-*.apk; do
[ -f "$f" ] || continue
n=$(basename "$f")
[ -f "/data/apk/$n" ] && cmp -s "$f" "/data/apk/$n" && continue
cp "$f" "/data/apk/.$n.tmp" && mv -f "/data/apk/.$n.tmp" "/data/apk/$n"
echo "copied $n"
done
cp /incoming/latest.json /data/apk/.latest.json.tmp
mv -f /data/apk/.latest.json.tmp /data/apk/latest.json
# keep the newest 5 APKs by version code
ls /data/apk | sed -n "s/^kvitt-\([0-9]*\)\.apk$/\1/p" | sort -n -r | tail -n +6 | while read -r v; do
rm -f "/data/apk/kvitt-$v.apk"; echo "removed kvitt-$v.apk"
done
echo "latest.json installed"'
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest (via this Pi):"
curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest" \
|| die "GET /apk/latest failed"
echo
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Kvitt Pi install (infra/README.md "Pi: what the owner does"). Run ON the Kvitt Pi, as the owner's user, from the
# bundle folder copied from the Mac (infra/pi/): cd ~/kvitt-pi && ./install.sh
# Idempotent: safe to run again after changing .env, the secrets, compose.yaml or the Caddyfile.
# 1. checks Docker Engine and the compose plugin
# 2. checks .env (no CHANGE_ME left in the keys the stack reads)
# 3. creates /opt/kvitt/{secrets,import,apk} and installs the secret files from ./secrets/ with their modes
# 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set
# 5. copies compose.yaml, Caddyfile, .env and the operation scripts to /opt/kvitt
# 6. pulls the images, gives the named volumes their owners, starts the stack, waits for /health
# Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration.
set -euo pipefail
bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
. "$bundle/lib.sh"
# 1. Docker
command -v docker >/dev/null || die "docker is not installed (README step 2)"
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing (README step 2)"
docker info >/dev/null 2>&1 || die "cannot talk to Docker as $(id -un): add yourself to the docker group (sudo usermod -aG docker $(id -un)), log out and in"
command -v curl >/dev/null || die "curl is missing: sudo apt-get install -y curl"
# 2. .env
env_src="$bundle/.env"
[[ -f "$env_src" ]] || die "no .env next to install.sh: cp .env.example .env and fill it (README step 4)"
for key in KVITT_REGISTRY KVITT_TAG HA_HOST HA_PORT HA_USER HA_CERT_DIR RCLONE_REMOTE RCLONE_FOLDER_ID; do
value=$(env_value "$key" "$env_src")
[[ -n "$value" ]] || die ".env: $key is empty"
[[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value"
done
registry=$(env_value KVITT_REGISTRY "$env_src")
registry_host=${registry%%/*}
registry_user=$(env_value KVITT_REGISTRY_USER "$env_src")
registry_token=$(env_value KVITT_REGISTRY_TOKEN "$env_src")
# 3. Directories and secrets. uid 1000 = cert-sync, uid 10001 = server and backup (SPEC §10.2, §10.7, D-023, D-105).
say "Creating $KVITT_DIR"
sudo install -d -m 0755 -o "$(id -u)" -g "$(id -g)" "$KVITT_DIR" "$KVITT_DIR/import" "$KVITT_DIR/apk"
sudo install -d -m 0700 -o root -g root "$KVITT_DIR/secrets"
# name : owner uid : group : mode
secret_specs=(
"fcm-service-account.json:0:10001:0440"
"ha_ssh_key:1000:1000:0600"
"ha_known_hosts:1000:1000:0600"
"rclone.conf:10001:10001:0600"
)
missing=()
for spec in "${secret_specs[@]}"; do
IFS=: read -r name uid gid mode <<< "$spec"
src="$bundle/secrets/$name"
dst="$KVITT_DIR/secrets/$name"
if [[ -f "$src" ]]; then
sudo install -m "$mode" -o "$uid" -g "$gid" "$src" "$dst"
say " secrets/$name installed (mode $mode, owner $uid:$gid)"
elif sudo test -f "$dst"; then
sudo chown "$uid:$gid" "$dst"
sudo chmod "$mode" "$dst"
say " secrets/$name already in place (mode $mode, owner $uid:$gid)"
else
missing+=("$name")
fi
if sudo test -d "$dst"; then
die "$dst is a directory (Docker creates one when a file was missing at start): sudo rmdir $dst, then run again"
fi
done
(( ${#missing[@]} == 0 )) || die "missing secret files: ${missing[*]} (put them in $bundle/secrets/, README step 5)"
if [[ -d "$bundle/secrets" ]]; then
say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets"
fi
# 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login.
daemon_json=/etc/docker/daemon.json
changed=$(sudo python3 - "$daemon_json" "$registry_host" <<'PY'
import json, os, sys
path, host = sys.argv[1], sys.argv[2]
cfg = {}
if os.path.exists(path):
with open(path) as f:
text = f.read().strip()
cfg = json.loads(text) if text else {}
regs = cfg.setdefault("insecure-registries", [])
if host in regs:
print("no")
else:
regs.append(host)
tmp = path + ".kvitt-tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
os.replace(tmp, path)
print("yes")
PY
)
if [[ "$changed" == yes ]]; then
say "Added $registry_host to insecure-registries in $daemon_json; restarting Docker"
sudo systemctl restart docker
else
say "$registry_host is already in insecure-registries"
fi
if [[ -n "$registry_user" && -n "$registry_token" ]]; then
printf '%s' "$registry_token" | docker login "$registry_host" -u "$registry_user" --password-stdin >/dev/null
say "Logged in to $registry_host as $registry_user"
else
say "No registry credentials in .env: pulling anonymously"
fi
# 5. Stack files and operation scripts.
install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml"
install -m 0644 "$bundle/Caddyfile" "$KVITT_DIR/Caddyfile"
install -m 0600 "$env_src" "$KVITT_DIR/.env"
for script in lib.sh deploy.sh bootstrap.sh backup-now.sh install-apk.sh; do
install -m 0755 "$bundle/$script" "$KVITT_DIR/$script"
done
say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR"
# 6. Pull, volume owners, start.
compose config --quiet
compose pull
compose create
# Named volumes start owned by root; give each its container's uid (the server image has no shell, so the
# backup image, which mounts the same data volume, sets /data). Runs as root once per install, then exits.
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh backup \
-c 'chown 10001:10001 /data /backups && mkdir -p /data/apk && chown 10001:10001 /data/apk'
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh cert-sync \
-c 'chown 1000:1000 /certs'
compose up -d --remove-orphans
ok=1
wait_healthy server 180 || ok=0
wait_healthy cert-sync 120 || { ok=0; say "cert-sync has no certificate yet: docker compose logs cert-sync (README, Troubleshooting)"; }
wait_healthy caddy 120 || ok=0
compose ps
print_health || ok=0
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
say "Kvitt is running. Next: $KVITT_DIR/bootstrap.sh (first install only)."
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Shared helpers for the Kvitt Pi scripts (install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh).
# Sourced, not run. These scripts run ON the Kvitt Pi as the owner's user (a member of the docker group).
# shellcheck disable=SC2034 # variables are used by the scripts that source this file
KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
PUBLIC_HOST=icehack.asuscomm.com
PUBLIC_PORT=8443
say() { printf '%s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
# Value of KEY in an env file (default /opt/kvitt/.env); empty when unset.
env_value() {
local file=${2:-$KVITT_DIR/.env}
sed -n "s/^$1=//p" "$file" | tail -n 1
}
# Set KEY=VALUE in an env file in place (adds the line when missing). Keeps the file's mode.
env_set() {
local key=$1 value=$2 file=${3:-$KVITT_DIR/.env} tmp
tmp=$(mktemp "$file.XXXXXX")
if grep -q "^$key=" "$file"; then
awk -v k="$key" -v v="$value" 'index($0, k "=") == 1 { print k "=" v; next } { print }' "$file" > "$tmp"
else
cat "$file" > "$tmp"
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
chmod --reference="$file" "$tmp"
mv -f "$tmp" "$file"
}
compose() {
docker compose --project-directory "$KVITT_DIR" -f "$KVITT_DIR/compose.yaml" --env-file "$KVITT_DIR/.env" "$@"
}
# Health status of a compose service's container: healthy, unhealthy, starting, or none.
service_health() {
local id
id=$(compose ps -q "$1" 2>/dev/null | head -n 1)
[[ -n "$id" ]] || { echo none; return; }
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id" 2>/dev/null || echo none
}
# Wait until a service is healthy (default 180 s). Returns 1 on timeout.
wait_healthy() {
local service=$1 timeout=${2:-180} waited=0 status
while :; do
status=$(service_health "$service")
[[ "$status" == healthy ]] && { say "$service: healthy"; return 0; }
if (( waited >= timeout )); then
say "$service: $status after ${timeout}s"
return 1
fi
sleep 5
waited=$((waited + 5))
done
}
# GET /health through Caddy on this Pi, with the real certificate check (no -k): proves TLS and the proxy path.
print_health() {
local out
if out=$(curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/health" 2>&1); then
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi): $out"
return 0
fi
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out"
return 1
}