Files

121 lines
5.2 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# Backup receipts include environment secrets. Protect them at creation, not only
# after chmod, so a failed copy/inspect never leaves a world-readable partial file.
umask 077
# Run on Unraid after building both images. Never source secret values into logs.
ROOT=/mnt/user/appdata/hope-voice
ENV_FILE="$ROOT/runtime.env"
test -f "$ENV_FILE"
test -d "$ROOT/accounts"
test -d "$ROOT/hermes"
test -f "$ROOT/livekit.yaml"
# CI runs inside a Docker-socket runner: host bind sources need not be visible
# in its filesystem. --mount fails for a missing host source (unlike -v, which
# would silently create it). Validate read-only through the host Docker daemon.
if [ ! -d /mnt/user/dev/hope-voice-api/certs ]; then
docker run --rm --network none --entrypoint /usr/bin/test \
--mount type=bind,source=/mnt/user/dev/hope-voice-api/certs,target=/certs,readonly \
hope-voice-api-voice:latest -d /certs
fi
# The runner also has its own loopback network. Probe the host namespace through
# a short-lived container, without exposing private control endpoints on the LAN.
host_curl() {
docker run --rm --network host --entrypoint curl hope-voice-api-voice:latest "$@"
}
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="$ROOT/rollback/$STAMP"
mkdir -p "$BACKUP"
chmod 700 "$ROOT" "$BACKUP"
# Capture original immutable IDs before installing state-aware rollback.
declare -A ORIGINAL
for name in voice hope-control; do
ORIGINAL[$name]=$(docker inspect --format '{{.Id}}' "$name" 2>/dev/null || true)
done
rollback() {
local code=$?
trap - ERR
set +e
for name in voice hope-control; do
current=$(docker inspect --format '{{.Id}}' "$name" 2>/dev/null)
# Never remove an original that failed before its rename. Remove only the
# replacement occupying the verified fixed name, then restore the original.
if [ -n "$current" ] && [ "$current" != "${ORIGINAL[$name]}" ]; then
if [ "$(docker inspect --format '{{.Name}}' "$current")" = "/$name" ]; then
docker rm -f "$current" >/dev/null
fi
fi
if [ -n "${ORIGINAL[$name]}" ]; then
original_name=$(docker inspect --format '{{.Name}}' "${ORIGINAL[$name]}" 2>/dev/null)
if [ "$original_name" = "/$name-rollback-$STAMP" ]; then
docker rename "${ORIGINAL[$name]}" "$name"
fi
if [ "$(docker inspect --format '{{.Name}}' "${ORIGINAL[$name]}" 2>/dev/null)" = "/$name" ]; then
docker start "${ORIGINAL[$name]}" >/dev/null
fi
fi
done
exit "$code"
}
trap rollback ERR
# Configuration receipts contain runtime secrets: root-only, never output them.
for name in voice hope-control; do
if [ -n "${ORIGINAL[$name]}" ]; then
docker inspect "$name" > "$BACKUP/$name.json"
chmod 600 "$BACKUP/$name.json"
test "$(docker inspect --format '{{.Name}}' "$name")" = "/$name"
docker stop "$name" >/dev/null
docker rename "$name" "$name-rollback-$STAMP"
fi
done
# Accounts are quiesced before SQLite/WAL and product uploads are copied.
# Migrations are additive; rollback containers retain the new database. The
# snapshot is separately retained for operator-approved data restoration, never
# silently overwrite messages created after deployment.
tar -C "$ROOT" -czf "$BACKUP/account-state.tgz" accounts
chmod 600 "$BACKUP/account-state.tgz"
cp "$ROOT/runtime.env" "$BACKUP/runtime.env"
cp "$ROOT/voice.env" "$BACKUP/voice.env"
cp "$ROOT/livekit.yaml" "$BACKUP/livekit.yaml"
chmod 600 "$BACKUP/runtime.env" "$BACKUP/voice.env" "$BACKUP/livekit.yaml"
# Per-account Hermes homes remain mounted in place. No container/data migration
# may delete these roots. Record the exact old identities without revealing envs.
for name in voice hope-control; do
if [ -n "${ORIGINAL[$name]}" ]; then
docker inspect --format '{{.Id}}' "${ORIGINAL[$name]}" > "$BACKUP/$name-container-id.txt"
fi
done
docker run -d --name hope-control --network host --restart unless-stopped \
--env-file "$ENV_FILE" \
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
-e HOPE_HERMES_DATA_DIR="$ROOT/hermes" \
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
-v "$ROOT/accounts:/var/lib/hope" \
-v "$ROOT/hermes:$ROOT/hermes" \
-v /var/run/docker.sock:/var/run/docker.sock \
--log-opt max-size=10m --log-opt max-file=3 hope-voice-control:latest >/dev/null
for attempt in $(seq 1 30); do
if host_curl -sf http://127.0.0.1:8092/health -o /dev/null; then break; fi
if [ "$attempt" = 30 ]; then false; fi
sleep 2
done
docker run -d --name voice --network host --restart unless-stopped \
--env-file "$ROOT/voice.env" \
-v /mnt/user/dev/hope-voice-api/certs:/etc/voice/certs \
-v "$ROOT/livekit.yaml:/etc/livekit.yaml:ro" \
--health-cmd='/opt/voice-agent/.venv/bin/python /opt/voice-agent/healthcheck.py' \
--health-interval=15s --health-timeout=5s --health-retries=3 --health-start-period=20s \
--log-opt max-size=10m --log-opt max-file=3 hope-voice-api-voice:latest >/dev/null
for attempt in $(seq 1 30); do
if [ "$(docker inspect --format '{{.State.Health.Status}}' voice)" = healthy ]; then break; fi
if [ "$attempt" = 30 ]; then false; fi
sleep 2
done
test "$(host_curl -sk -o /dev/null -w '%{http_code}' -X POST https://127.0.0.1:9178/token)" = 401
trap - ERR
printf 'Deployment healthy; rollback containers retained with suffix %s\n' "$STAMP"