2476667510
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
135 lines
6.0 KiB
Bash
Executable File
135 lines
6.0 KiB
Bash
Executable File
#!/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)."
|