#!/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"