Pi: minimal self-contained compose project (D-125)
The Kvitt Pi runs Kvitt from /home/jacobhagstedt/kvitt: compose.yaml, .env, secrets/ and relative bind mounts ./data, ./backups, ./import. Both containers run as the owner's uid/gid (KVITT_UID, KVITT_GID). No install script, no /opt, no Docker daemon or system changes; operations are plain docker compose commands (README "Pi: what the owner does", RESTORE.md). Removed install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh. .env holds only the keys compose.yaml reads; RCLONE_REMOTE is fixed to gdrive: in compose.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+14
-29
@@ -1,32 +1,17 @@
|
||||
# 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 CHANGE_ME left in a key it checks.
|
||||
# No secret files here: those go to /opt/kvitt/secrets/ (infra/README.md "Pi: what the owner does").
|
||||
# Kvitt on the Kvitt Pi (SPEC §10.2, D-125). Copy to .env next to compose.yaml and set HA_HOST; check KVITT_UID and
|
||||
# KVITT_GID against `id -u` and `id -g` on the Pi. Every key is read by compose.yaml and by nothing else.
|
||||
|
||||
# --- Read by compose.yaml -----------------------------------------------------------------------------------------
|
||||
|
||||
# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-backup.
|
||||
# compose.yaml: image names ${KVITT_REGISTRY}/kvitt-server and /kvitt-backup (D-104).
|
||||
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
|
||||
# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address.
|
||||
# NPM holds its Let's Encrypt certificate and serves it on the default HTTPS port 443, which the router already
|
||||
# forwards to NPM for Home Assistant (D-124). Invite links and the landing page use https://<KVITT_PUBLIC_HOST>
|
||||
# (KVITT_PUBLIC_URL, no port). Not icehack.asuscomm.com: that name is NPM's proxy host for Home Assistant.
|
||||
KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST
|
||||
# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port
|
||||
# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the
|
||||
# address NPM's connections come from: an IP literal such as 192.168.1.20, not a host name (install.sh checks it).
|
||||
# Kept the name HA_HOST (the proxy host); it no longer has the SSH settings HA_PORT, HA_USER, HA_CERT_DIR of cert-sync.
|
||||
HA_HOST=CHANGE_ME_HA_HOST
|
||||
# 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).
|
||||
# compose.yaml: image tag of both services; deploy or roll back = change it, then pull and up -d.
|
||||
KVITT_TAG=v1.0.0
|
||||
# compose.yaml: the server's KVITT_PUBLIC_URL=https://<this>, used in invite links and the landing page (D-124).
|
||||
KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org
|
||||
# compose.yaml: the server's KVITT_TRUSTED_PROXY, the Home Assistant Pi's (NPM's) LAN IPv4 address, e.g. 192.168.1.20.
|
||||
HA_HOST=CHANGE_ME
|
||||
# compose.yaml: uid both containers run as, `id -u` of the user that owns ~/kvitt on the Pi.
|
||||
KVITT_UID=1000
|
||||
# compose.yaml: gid both containers run as, `id -g` of the same user.
|
||||
KVITT_GID=1000
|
||||
# compose.yaml: the backup's Google Drive folder id (OQ-4).
|
||||
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=
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Filled on the Mac or the Pi, never committed: host addresses and registry credentials.
|
||||
# Filled on the Mac, never committed: the Pi's .env (host address) and the secrets staged for the copy to the Pi.
|
||||
.env
|
||||
# publish-apk.sh output (APKs, latest.json, R8 mappings) and secrets staged for the copy to the Pi.
|
||||
out/
|
||||
secrets/
|
||||
# publish-apk.sh output (APKs, latest.json, R8 mappings).
|
||||
out/
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
+45
-30
@@ -1,9 +1,11 @@
|
||||
# Kvitt production stack on the Kvitt Pi (SPEC §10.2). Deployed by install.sh to /opt/kvitt/compose.yaml together
|
||||
# with /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt.
|
||||
# TLS terminates in Nginx Proxy Manager on the Home Assistant Pi (SPEC §10.5, D-120), which forwards plain HTTP over
|
||||
# the LAN to the server's published port 8080. Only the server publishes a port; backup is reachable only on the
|
||||
# internal network `kvitt` (SPEC §8.9). The router forwards the internet only to NPM, never to this Pi.
|
||||
# Image names: ${KVITT_REGISTRY}/kvitt-<name>, the names the Gitea Actions workflow pushes (D-104).
|
||||
# Kvitt on the Kvitt Pi (SPEC §10.2, D-125): a self-contained compose project in /home/jacobhagstedt/kvitt.
|
||||
# That folder holds this file, .env (from .env.example), secrets/ and the bind-mounted folders data/, backups/
|
||||
# and import/; nothing outside it is used. Run every command there (infra/README.md "Pi: what the owner does").
|
||||
# Both containers run as the owner's uid/gid (KVITT_UID, KVITT_GID in .env), so every file they write in data/ and
|
||||
# backups/ belongs to the owner. Create data/ (and data/apk), backups/ and import/ as the owner before the first
|
||||
# start: a folder Docker creates itself belongs to root and the containers cannot write to it.
|
||||
# TLS terminates in Nginx Proxy Manager on the Home Assistant Pi (SPEC §10.5, D-120), which forwards plain HTTP
|
||||
# over the LAN to the server's published port 8080, the only published port.
|
||||
|
||||
name: kvitt
|
||||
|
||||
@@ -18,13 +20,14 @@ services:
|
||||
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"
|
||||
user: "${KVITT_UID:?set KVITT_UID in .env}:${KVITT_GID:?set KVITT_GID in .env}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 160m
|
||||
# On all interfaces of the Pi: NPM on the Home Assistant Pi connects to this Pi's LAN address, port 8080.
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
@@ -32,14 +35,23 @@ services:
|
||||
KVITT_DATA_DIR: /data
|
||||
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}
|
||||
# NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry
|
||||
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it).
|
||||
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IPv4 address, not a host name.
|
||||
KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env}
|
||||
# The remaining server settings keep their SPEC §10.4 defaults (KVITT_FCM_PROJECT_ID kvitt-1337, ...).
|
||||
# The other server settings keep their SPEC §10.4 defaults (KVITT_FCM_CREDENTIALS
|
||||
# /secrets/fcm-service-account.json, KVITT_FCM_PROJECT_ID kvitt-1337, ...).
|
||||
volumes:
|
||||
- data:/data
|
||||
- /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro
|
||||
# SQLite database, pictures and APKs (data/apk/).
|
||||
- ./data:/data
|
||||
# Only the FCM key, not rclone.conf (D-117). create_host_path false: a missing file stops the start with
|
||||
# "bind source path does not exist" instead of Docker creating a root-owned directory in its place.
|
||||
- type: bind
|
||||
source: ./secrets/fcm-service-account.json
|
||||
target: /secrets/fcm-service-account.json
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
# Empty except during the one-off Splitwise import (SPEC §10.8, D-025).
|
||||
- /opt/kvitt/import:/import:ro
|
||||
- ./import:/import:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "kvitt-server", "healthcheck"]
|
||||
interval: 30s
|
||||
@@ -47,26 +59,38 @@ services:
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
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"
|
||||
# The server's uid, so sqlite3 .backup can open the WAL database files the server owns (D-105).
|
||||
user: "${KVITT_UID}:${KVITT_GID}"
|
||||
read_only: true
|
||||
# backup.sh's private copy of rclone.conf and the start marker of the healthcheck live here.
|
||||
tmpfs:
|
||||
- /tmp:size=16m
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 96m
|
||||
environment:
|
||||
TZ: Europe/Stockholm
|
||||
RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:}
|
||||
# The owner's uid has no account in the image; rclone keeps its cache under HOME.
|
||||
HOME: /tmp
|
||||
# The [gdrive] remote in secrets/rclone.conf.
|
||||
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
|
||||
- ./data:/data
|
||||
- ./backups:/backups
|
||||
# Only rclone.conf (Drive token), read-only; backup.sh copies it to /tmp because rclone writes refreshed
|
||||
# tokens back (D-105).
|
||||
- type: bind
|
||||
source: ./secrets/rclone.conf
|
||||
target: /config/rclone/rclone.conf
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck.sh"]
|
||||
interval: 5m
|
||||
@@ -74,12 +98,3 @@ services:
|
||||
retries: 1
|
||||
start_period: 30s
|
||||
logging: *logging
|
||||
networks: [kvitt]
|
||||
|
||||
volumes:
|
||||
data:
|
||||
backups:
|
||||
|
||||
networks:
|
||||
kvitt:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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, prints the pulled image digests and appends them to /opt/kvitt/deployed-digests (D-117),
|
||||
# docker compose up -d, waits for server to be healthy, prints GET /health on port 8080 and through Nginx
|
||||
# Proxy Manager (D-120; a failure through NPM is a warning, the deploy itself succeeded)
|
||||
# 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,11p' "$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
|
||||
record_digests "$tag"
|
||||
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 GET /health on port 8080 failed (docker compose logs server)"
|
||||
print_proxy_health || say "WARNING: /health through Nginx Proxy Manager failed: Kvitt is not reachable from outside (README, Troubleshooting)"
|
||||
say "Deployed $tag."
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/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 $SERVER_URL/apk/latest:"
|
||||
curl -fsS --max-time 15 "$SERVER_URL/apk/latest" || die "GET /apk/latest failed"
|
||||
echo
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/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 or compose.yaml.
|
||||
# 1. checks Docker Engine and the compose plugin
|
||||
# 2. checks .env (no CHANGE_ME left in the keys the stack reads; KVITT_PUBLIC_HOST a host name, HA_HOST an IPv4
|
||||
# address)
|
||||
# 3. creates /opt/kvitt/{secrets,import,apk}, installs the secret files from ./secrets/ with their modes and
|
||||
# removes what the Caddy and cert-sync stack of before D-120 left in /opt/kvitt
|
||||
# 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set
|
||||
# 5. copies compose.yaml, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set
|
||||
# in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag
|
||||
# 6. pulls the images (digests recorded in /opt/kvitt/deployed-digests), gives the named volumes their owners,
|
||||
# starts the stack, waits for /health on port 8080, then tries /health through Nginx Proxy Manager
|
||||
# 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"
|
||||
command -v python3 >/dev/null || die "python3 is missing (step 4 edits daemon.json with it): sudo apt-get install -y python3"
|
||||
|
||||
# 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 KVITT_PUBLIC_HOST HA_HOST 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
|
||||
# The server compares the TCP peer with KVITT_TRUSTED_PROXY (= HA_HOST). A host name would have to resolve inside
|
||||
# the container to exactly NPM's LAN address (mDNS names such as homeassistant.local do not), so require the address.
|
||||
ha_host=$(env_value HA_HOST "$env_src")
|
||||
[[ "$ha_host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \
|
||||
|| die ".env: HA_HOST must be the Home Assistant Pi's LAN IPv4 address (for example 192.168.1.20), not $ha_host"
|
||||
public_host=$(env_value KVITT_PUBLIC_HOST "$env_src")
|
||||
[[ "$public_host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] \
|
||||
|| die ".env: KVITT_PUBLIC_HOST must be a host name such as kvitt.example.org (no scheme, no port), not $public_host"
|
||||
[[ "$public_host" != icehack.asuscomm.com ]] \
|
||||
|| die ".env: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)"
|
||||
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 10001 = server and backup (SPEC §10.2, §10.7, 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"
|
||||
"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
|
||||
# Left by the Caddy and cert-sync stack of before D-120: the Home Assistant SSH key must not stay on this Pi.
|
||||
for old in "$KVITT_DIR/secrets/ha_ssh_key" "$KVITT_DIR/secrets/ha_known_hosts" "$KVITT_DIR/Caddyfile"; do
|
||||
if sudo test -e "$old"; then
|
||||
sudo rm -rf "$old"
|
||||
say " removed $old (not used since D-120)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 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.
|
||||
installed_tag=""
|
||||
[[ -f "$KVITT_DIR/.env" ]] && installed_tag=$(env_value KVITT_TAG)
|
||||
bundle_tag=$(env_value KVITT_TAG "$env_src")
|
||||
install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml"
|
||||
install -m 0600 "$env_src" "$KVITT_DIR/.env"
|
||||
if [[ -n "$installed_tag" && "$installed_tag" != "$bundle_tag" ]]; then
|
||||
env_set KVITT_TAG "$installed_tag"
|
||||
say "KVITT_TAG stays at $installed_tag (set by deploy.sh; the bundle's .env says $bundle_tag). Other tag: $KVITT_DIR/deploy.sh <tag>"
|
||||
fi
|
||||
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, .env and the scripts in $KVITT_DIR"
|
||||
|
||||
# 6. Pull, volume owners, start.
|
||||
compose config --quiet
|
||||
compose pull
|
||||
record_digests "$(env_value KVITT_TAG)"
|
||||
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'
|
||||
# --remove-orphans also removes the caddy and cert-sync containers of a stack installed before D-120.
|
||||
compose up -d --remove-orphans
|
||||
# Their volumes: certs held a copy of the certificate's private key.
|
||||
for volume in kvitt_certs kvitt_caddy_data; do
|
||||
if docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||
if docker volume rm "$volume" >/dev/null; then
|
||||
say "Removed the volume $volume (not used since D-120)"
|
||||
else
|
||||
say "Could not remove the volume $volume (still in use?): docker volume rm $volume"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
ok=1
|
||||
wait_healthy server 180 || ok=0
|
||||
compose ps
|
||||
print_health || ok=0
|
||||
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
|
||||
if ! print_proxy_health; then
|
||||
say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 6)."
|
||||
fi
|
||||
say "Kvitt is running. Next: README step 6 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
#!/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}
|
||||
# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120).
|
||||
SERVER_URL=http://localhost:8080
|
||||
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi, the default HTTPS port that Kvitt's public URL uses
|
||||
# (the router's existing external 443 forward reaches it, D-124).
|
||||
NPM_HTTPS_PORT=443
|
||||
|
||||
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" \
|
||||
|| { rm -f "$tmp"; return 1; }
|
||||
else
|
||||
{ cat "$file" && printf '%s=%s\n' "$key" "$value"; } > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
fi
|
||||
if ! { chmod --reference="$file" "$tmp" && mv -f "$tmp" "$file"; }; then rm -f "$tmp"; return 1; fi
|
||||
}
|
||||
|
||||
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 from the server's published port on this Pi.
|
||||
print_health() {
|
||||
local out
|
||||
if out=$(curl -fsS --max-time 15 "$SERVER_URL/health" 2>&1); then
|
||||
say "GET $SERVER_URL/health: $out"
|
||||
return 0
|
||||
fi
|
||||
say "GET $SERVER_URL/health failed: $out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# GET /health through Nginx Proxy Manager on the Home Assistant Pi (HA_HOST in .env) for KVITT_PUBLIC_HOST, with
|
||||
# the real certificate check (no -k): proves NPM's proxy host, its certificate and the LAN path to this Pi's port
|
||||
# 8080. The router forward is not on this path.
|
||||
print_proxy_health() {
|
||||
local out ha_host host
|
||||
ha_host=$(env_value HA_HOST)
|
||||
host=$(env_value KVITT_PUBLIC_HOST)
|
||||
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host/health" 2>&1); then
|
||||
say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT): $out"
|
||||
return 0
|
||||
fi
|
||||
say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT) failed: $out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Print the registry digest of every image the stack runs and append one line per image to
|
||||
# $KVITT_DIR/deployed-digests: "<UTC time> <KVITT_TAG> <image reference> <digest>" (D-117). The registry is plain
|
||||
# HTTP on the LAN (D-001), so a swapped image would otherwise go unnoticed: a tag that was recorded before with a
|
||||
# different digest prints a WARNING (expected only for a tag like `latest` that is meant to move).
|
||||
record_digests() {
|
||||
local tag=$1 log=$KVITT_DIR/deployed-digests stamp ref repo digest prev d
|
||||
local -a refs repo_digests
|
||||
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
mapfile -t refs < <(compose config --images)
|
||||
touch "$log"
|
||||
say "Image digests (also in $log):"
|
||||
for ref in "${refs[@]}"; do
|
||||
[[ -n "$ref" ]] || continue
|
||||
digest=""
|
||||
if [[ "$ref" == *@sha256:* ]]; then
|
||||
digest=${ref#*@}
|
||||
else
|
||||
repo=${ref%:*}
|
||||
read -r -a repo_digests <<< "$(docker image inspect --format '{{range .RepoDigests}}{{.}} {{end}}' "$ref" 2>/dev/null || true)"
|
||||
for d in "${repo_digests[@]}"; do
|
||||
if [[ "$d" == "$repo@"* ]]; then digest=${d#*@}; break; fi
|
||||
done
|
||||
fi
|
||||
[[ -n "$digest" ]] || digest=unknown
|
||||
prev=$(awk -v r="$ref" '$3 == r { d = $4 } END { print d }' "$log")
|
||||
if [[ -n "$prev" && "$prev" != "$digest" ]]; then
|
||||
say "WARNING: $ref was $prev at an earlier deploy and is $digest now: the image behind this tag changed"
|
||||
fi
|
||||
say " $ref $digest"
|
||||
printf '%s %s %s %s\n' "$stamp" "$tag" "$ref" "$digest" >> "$log"
|
||||
done
|
||||
}
|
||||
Reference in New Issue
Block a user