This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user