Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+119
View File
@@ -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"