#!/bin/sh set -eu umask 077 usage() { echo "Usage: restore-empty-target.sh --project devrunbook-*-restore-* --backup /absolute/backup --env-file /absolute/env [--dry-run]" >&2 } PROJECT='' BACKUP='' ENV_FILE='' DRY_RUN=false while [ "$#" -gt 0 ]; do case "$1" in --project) PROJECT=${2-}; shift 2 ;; --backup) BACKUP=${2-}; shift 2 ;; --env-file) ENV_FILE=${2-}; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; *) usage; exit 64 ;; esac done case "$PROJECT" in devrunbook-*-restore-*) ;; *) echo 'Restore project must match devrunbook-*-restore-*.' >&2; exit 64 ;; esac case "$PROJECT" in *[!a-zA-Z0-9_-]*) echo 'Invalid Compose project name.' >&2; exit 64 ;; esac case "$BACKUP" in /*) ;; *) echo 'Backup path must be absolute.' >&2; exit 64 ;; esac case "$ENV_FILE" in /*) ;; *) echo 'Environment file must be absolute.' >&2; exit 64 ;; esac [ -d "$BACKUP" ] || { echo 'Backup directory does not exist.' >&2; exit 66; } [ -f "$ENV_FILE" ] || { echo 'Environment file does not exist.' >&2; exit 66; } for file in database.dump artifacts.tar.gz operator-content.tar.gz metadata.json SHA256SUMS; do [ -f "$BACKUP/$file" ] || { echo "Backup file missing: $file" >&2; exit 66; } done [ -z "$(find "$BACKUP" -maxdepth 1 -type l -print -quit)" ] || { echo 'Backup directory may not contain symbolic links.' >&2; exit 65; } if "$DRY_RUN"; then printf 'Validated empty-target restore request for %s from %s\n' "$PROJECT" "$BACKUP" exit 0 fi for command in docker python3 sha256sum; do command -v "$command" >/dev/null 2>&1 || { echo "Required command missing: $command" >&2; exit 69; } done ( cd "$BACKUP" sha256sum --check --strict SHA256SUMS ) python3 - "$BACKUP" <<'PY' import json, pathlib, sys, tarfile root = pathlib.Path(sys.argv[1]).resolve(strict=True) with (root / "metadata.json").open(encoding="utf-8") as source: metadata = json.load(source) if metadata.get("schemaVersion") != 1 or metadata.get("secretsIncluded") is not False: raise SystemExit("Unsupported or unsafe backup metadata.") expected = {"database.dump", "artifacts.tar.gz", "operator-content.tar.gz"} files = metadata.get("files") if not isinstance(files, dict) or set(files) != expected: raise SystemExit("Backup metadata file inventory is invalid.") for name, expected_size in files.items(): if not isinstance(expected_size, int) or expected_size < 0 or (root / name).stat().st_size != expected_size: raise SystemExit(f"Backup size metadata mismatch: {name}") for name in ("artifacts.tar.gz", "operator-content.tar.gz"): with tarfile.open(root / name, "r:gz") as archive: for member in archive: path = pathlib.PurePosixPath(member.name) if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk() or member.isdev(): raise SystemExit(f"Unsafe archive member in {name}: {member.name}") PY [ -z "$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT")" ] || { echo 'Restore target already has containers; refusing to continue.' >&2; exit 73; } [ -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$PROJECT")" ] || { echo 'Restore target already has volumes; refusing to continue.' >&2; exit 73; } compose() { docker compose -p "$PROJECT" --env-file "$ENV_FILE" "$@"; } compose build migrate web worker >/dev/null compose create postgres web worker >/dev/null POSTGRES_CONTAINER=$(compose ps -aq postgres) WEB_CONTAINER=$(compose ps -aq web) POSTGRES_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$POSTGRES_CONTAINER") ARTIFACT_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/artifacts"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER") OPERATOR_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/operator-content"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER") for volume in "$POSTGRES_VOLUME" "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do [ -n "$volume" ] || { echo 'A target volume could not be resolved.' >&2; exit 69; } [ "$(docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$volume")" = "$PROJECT" ] || { echo "Resolved volume $volume is outside the restore project." >&2; exit 69; } done POSTGRES_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER") WEB_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$WEB_CONTAINER") ARCHIVE_UID_GID=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --entrypoint sh "$WEB_IMAGE" -c 'printf "%s:%s" "$(id -u)" "$(id -g)"') case "$ARCHIVE_UID_GID" in *[!0-9:]*) echo 'Web image returned an invalid archive UID/GID.' >&2; exit 69 ;; esac for volume in "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do ENTRY_COUNT=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" -v "$volume:/source:ro" --entrypoint sh "$POSTGRES_IMAGE" -c 'find /source -mindepth 1 -maxdepth 1 -print -quit') [ -z "$ENTRY_COUNT" ] || { echo "Target volume $volume is not empty." >&2; exit 73; } done compose start postgres >/dev/null ATTEMPT=0 until compose exec -T postgres pg_isready --username devrunbook --dbname devrunbook >/dev/null 2>&1; do ATTEMPT=$((ATTEMPT + 1)) [ "$ATTEMPT" -lt 30 ] || { echo 'Target PostgreSQL did not become ready.' >&2; exit 69; } sleep 1 done USER_TABLE_COUNT=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \ "select count(*) from pg_tables where schemaname not in ('pg_catalog', 'information_schema')") [ "$USER_TABLE_COUNT" -eq 0 ] || { echo 'Target database is not empty.' >&2; exit 73; } compose exec -T postgres pg_restore --username devrunbook --dbname devrunbook --exit-on-error --no-owner --no-privileges < "$BACKUP/database.dump" docker run --rm -i --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" -v "$ARTIFACT_VOLUME:/target" --entrypoint tar "$POSTGRES_IMAGE" \ -C /target -xzf - < "$BACKUP/artifacts.tar.gz" docker run --rm -i --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" -v "$OPERATOR_VOLUME:/target" --entrypoint tar "$POSTGRES_IMAGE" \ -C /target -xzf - < "$BACKUP/operator-content.tar.gz" compose up -d migrate compose up -d web worker printf 'Restore completed into isolated project %s. Run application-level verification before acceptance.\n' "$PROJECT"