diff --git a/.github/workflows/phase-admin.yml b/.github/workflows/phase-admin.yml new file mode 100644 index 000000000..2aa97669d --- /dev/null +++ b/.github/workflows/phase-admin.yml @@ -0,0 +1,20 @@ +name: Phase admin + +on: + pull_request: + paths: + - phase-admin + - tests/phase-admin-compose.yml + - tests/test-phase-admin-compose.sh + - tests/test-phase-admin.sh + - .github/workflows/phase-admin.yml + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck phase-admin tests/test-phase-admin.sh tests/test-phase-admin-compose.sh + - run: bash -n phase-admin tests/test-phase-admin.sh tests/test-phase-admin-compose.sh + - run: tests/test-phase-admin.sh + - run: tests/test-phase-admin-compose.sh diff --git a/.gitignore b/.gitignore index f167ef0c8..826b8aa4c 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ celerybeat.pid # Environments .env +.env.before-restore.* .venv env/ venv/ @@ -168,6 +169,7 @@ next-env.d.ts # phase *.phase.json +phase-backup*.tar.enc # graphql_schema --out artifact — regenerated locally; only the # frontend/apollo/schema.graphql copy is tracked. diff --git a/README.md b/README.md index 723bca2d7..90855302a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The quickest and most reliable way to get started is by signing up on the [Phase | | **Deploy Phase Console on your infrastructure** | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | |![Docker](img/docker.svg) | [Docker Compose](https://docs.phase.dev/self-hosting/docker-compose) | +| 💾 | [Back up and restore a Docker Compose instance](docs/self-hosted-backup.md) | |![Kubernetes](img/kubernetes.svg) | [Kubernetes (Helm chart)](https://docs.phase.dev/self-hosting/kubernetes) | |![AWS](img/aws.svg) | [AWS](https://docs.phase.dev/self-hosting/aws) | |![AWS EKS](img/aws-eks.svg) | [AWS EKS (Helm chart)](https://docs.phase.dev/self-hosting/aws-eks) | diff --git a/docs/self-hosted-backup.md b/docs/self-hosted-backup.md new file mode 100644 index 000000000..125dbc64d --- /dev/null +++ b/docs/self-hosted-backup.md @@ -0,0 +1,53 @@ +# Back up and restore a self-hosted Phase instance + +`phase-admin` backs up the default Phase Docker Compose deployment. The encrypted archive contains the PostgreSQL database, the `.env` file needed to decrypt secrets, the resolved Compose configuration, an instance manifest, and checksums. + +## Create and verify a backup + +Run these commands from the Console repository while Phase is running: + +```bash +./phase-admin backup --output phase-backup.tar.enc +./phase-admin verify phase-backup.tar.enc +``` + +The script prompts for a password of at least 12 characters and encrypts the archive with AES-256-CBC and 200,000 PBKDF2 iterations. For unattended jobs, provide the password through the environment instead of a command-line argument: + +```bash +PHASE_BACKUP_PASSWORD="$BACKUP_PASSWORD" ./phase-admin backup --output phase-backup.tar.enc +``` + +Store the archive password separately from the archive. Test restores regularly, and copy completed archives off the Phase host according to your retention policy. + +## Restore + +Verify the incoming archive first: + +```bash +./phase-admin verify phase-backup.tar.enc +``` + +The manifest records the source `console_revision` plus the backend and frontend container image identifiers. Use the source Console revision for the restore; after a successful restore, upgrade Phase normally. Image identifiers are diagnostic metadata and may be `unknown` if those containers were stopped during backup. The script verifies archive integrity but does not decide whether versions are compatible, and restoring with an older Console version is not supported. + +Next, create and verify a fresh backup of the current instance so the restore can be reversed if needed: + +```bash +./phase-admin backup --output pre-restore-backup.tar.enc +./phase-admin verify pre-restore-backup.tar.enc +``` + +For a same-instance restore, keep the existing `.env`; the script rejects a restore if its database identity differs from the archive. For migration to a new host, copy the encrypted archive into the checkout, remove any bootstrap `.env`, and start with an empty `phase-postgres-data` volume. The archived `.env` initializes the new database. + +```bash +./phase-admin restore phase-backup.tar.enc +``` + +For automation, append `--yes` to skip the destructive confirmation. Use it only after separately verifying the archive. + +Restore verifies every archived file before making changes, shows the source manifest and target, stops services that can write to PostgreSQL, saves an existing `.env` as `.env.before-restore.`, and replaces the public database schema in one transaction. It then runs migrations and starts the full deployment. + +The archive contains secrets and configuration in encrypted form. Do not commit it or the saved `.env` files to source control. + +## Scope + +PostgreSQL is the only persistent application volume in the default Compose deployment, so this backup includes organizations, applications, environments, secret history, memberships, roles, service accounts, integrations, audit logs, and encryption metadata. Redis is transient and is not backed up. Host-managed TLS certificates and external reverse-proxy configuration are outside the Compose deployment and must be backed up separately. diff --git a/phase-admin b/phase-admin new file mode 100755 index 000000000..a8b56672a --- /dev/null +++ b/phase-admin @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +WORK_DIR= +OUTPUT_TMP= +ROLLBACK_ENV= +RESTORE_COMMITTED=false + +cleanup() { + local status=$? + if ((status != 0)) && [[ -n "$ROLLBACK_ENV" ]]; then + cp "$ROLLBACK_ENV" "$ROOT_DIR/.env" + (cd "$ROOT_DIR" && docker compose up -d >/dev/null 2>&1) || true + printf 'Restore failed; restored %s from %s.\n' "$ROOT_DIR/.env" "$ROLLBACK_ENV" >&2 + elif ((status != 0)) && [[ "$RESTORE_COMMITTED" == true ]]; then + (cd "$ROOT_DIR" && docker compose up -d >/dev/null 2>&1) || true + printf 'The database was restored, but migrations or service startup failed. Fix the error, then run: docker compose run --rm migrations && docker compose up -d\n' >&2 + fi + [[ -z "$WORK_DIR" ]] || rm -rf "$WORK_DIR" + [[ -z "$OUTPUT_TMP" ]] || rm -f "$OUTPUT_TMP" + exit "$status" +} +trap cleanup EXIT + +fail() { + printf 'phase-admin: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: + ./phase-admin backup [--output OUTPUT] + ./phase-admin verify ARCHIVE + ./phase-admin restore ARCHIVE [--yes] + +Creates, verifies, or restores a password-encrypted backup of the repository's +default Docker Compose deployment. Set PHASE_BACKUP_PASSWORD for +non-interactive use; otherwise the password is read from the terminal. + +Run ./phase-admin COMMAND --help for command-specific options. +EOF +} + +backup_usage() { + cat <<'EOF' +Usage: ./phase-admin backup [--output OUTPUT] + +Creates an encrypted backup. OUTPUT defaults to a timestamped file in the +current directory. +EOF +} + +verify_usage() { + cat <<'EOF' +Usage: ./phase-admin verify ARCHIVE + +Checks the encrypted archive format, member types, and checksums, then prints +its manifest. Verification does not require Docker. +EOF +} + +restore_usage() { + cat <<'EOF' +Usage: ./phase-admin restore ARCHIVE [--yes] + +Verifies ARCHIVE, shows its manifest and target, then replaces the database and +.env. --yes skips the destructive confirmation and is intended for automation. +EOF +} + +require_archive_tools() { + local tool + for tool in openssl tar; do + command -v "$tool" >/dev/null || fail "$tool is required" + done +} + +require_compose() { + require_archive_tools + command -v docker >/dev/null || fail 'docker is required' + docker compose version >/dev/null 2>&1 || fail 'Docker Compose v2 is required' +} + +read_password() { + local confirm=${1:-false} + PASSWORD=${PHASE_BACKUP_PASSWORD:-} + unset PHASE_BACKUP_PASSWORD + if [[ -z "$PASSWORD" ]]; then + [[ -t 0 ]] || fail 'set PHASE_BACKUP_PASSWORD when running non-interactively' + read -r -s -p 'Backup password: ' PASSWORD + printf '\n' >&2 + [[ -n "$PASSWORD" ]] || fail 'password cannot be empty' + if [[ "$confirm" == true ]]; then + local repeated + read -r -s -p 'Confirm password: ' repeated + printf '\n' >&2 + [[ "$PASSWORD" == "$repeated" ]] || fail 'passwords do not match' + fi + fi + ((${#PASSWORD} >= 12)) || fail 'password must be at least 12 characters' +} + +sha256() { + openssl dgst -sha256 "$1" | awk '{print $NF}' +} + +env_value() { + awk -v key="$2" 'index($0, key "=") == 1 {value = substr($0, length(key) + 2)} END {print value}' "$1" +} + +validate_phase_env() { + local file=$1 key + for key in NEXTAUTH_SECRET SECRET_KEY SERVER_SECRET DATABASE_NAME DATABASE_USER DATABASE_PASSWORD; do + [[ -n "$(env_value "$file" "$key")" ]] || fail "$file is missing $key" + done +} + +new_work_dir() { + WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/phase-backup.XXXXXXXX") + chmod 700 "$WORK_DIR" +} + +extract_and_verify() { + local archive=$1 listing expected actual file tarball + [[ -f "$archive" ]] || fail "archive not found: $archive" + new_work_dir + + tarball="$WORK_DIR/archive.tar" + openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -in "$archive" -out "$tarball" -pass fd:3 3<<<"$PASSWORD" + listing=$(tar -tzf "$tarball" | sort) + expected=$'checksums.txt\ncompose-config.yaml\ndatabase.dump\nmanifest.txt\nphase.env' + [[ "$listing" == "$expected" ]] || fail 'archive contains unexpected or missing files' + [[ -z "$(tar -tvzf "$tarball" | awk '$1 !~ /^-/ {print}')" ]] || fail 'archive contains a non-regular file' + tar -C "$WORK_DIR" -xzf "$tarball" + rm "$tarball" + + for file in database.dump phase.env compose-config.yaml manifest.txt; do + expected=$(awk -v name="$file" '$2 == name {print $1}' "$WORK_DIR/checksums.txt") + [[ -n "$expected" ]] || fail "missing checksum for $file" + actual=$(sha256 "$WORK_DIR/$file") + [[ "$actual" == "$expected" ]] || fail "checksum mismatch for $file" + done + grep -qx 'format_version=1' "$WORK_DIR/manifest.txt" || fail 'unsupported backup format version' + grep -qx 'dump_format=plain' "$WORK_DIR/manifest.txt" || fail 'unsupported database dump format' + grep -qx 'archive_compression=gzip' "$WORK_DIR/manifest.txt" || fail 'unsupported archive compression' + validate_phase_env "$WORK_DIR/phase.env" +} + +backup() { + local output=${1:-"phase-backup-$(date -u +%Y%m%dT%H%M%SZ).tar.enc"} + [[ -f "$ROOT_DIR/.env" ]] || fail "$ROOT_DIR/.env does not exist" + validate_phase_env "$ROOT_DIR/.env" + [[ ! -e "$output" ]] || fail "refusing to overwrite $output" + [[ -d "$(dirname "$output")" ]] || fail "output directory does not exist: $(dirname "$output")" + output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")" + + require_compose + read_password true + cd "$ROOT_DIR" + docker compose ps --status running --services | grep -qx postgres || fail 'the postgres service is not running' + new_work_dir + + docker compose exec -T postgres sh -c \ + 'pg_dump --format=plain --no-owner --no-privileges --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ + >"$WORK_DIR/database.dump" + cp .env "$WORK_DIR/phase.env" + docker compose config >"$WORK_DIR/compose-config.yaml" + + local backend_image frontend_image console_revision latest_migration migration_count + backend_image=$(docker inspect phase-backend --format '{{.Config.Image}}@{{.Image}}' 2>/dev/null || printf 'unknown') + frontend_image=$(docker inspect phase-frontend --format '{{.Config.Image}}@{{.Image}}' 2>/dev/null || printf 'unknown') + console_revision=$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf 'unknown') + latest_migration=$(docker compose exec -T postgres sh -c \ + 'psql --tuples-only --no-align --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" --command="SELECT app || chr(58) || name FROM django_migrations ORDER BY applied DESC LIMIT 1"' | tr -d '\r') + migration_count=$(docker compose exec -T postgres sh -c \ + 'psql --tuples-only --no-align --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" --command="SELECT count(*) FROM django_migrations"' | tr -d '\r') + + cat >"$WORK_DIR/manifest.txt" <"$WORK_DIR/checksums.txt" + local file + for file in database.dump phase.env compose-config.yaml manifest.txt; do + printf '%s %s\n' "$(sha256 "$WORK_DIR/$file")" "$file" >>"$WORK_DIR/checksums.txt" + done + + OUTPUT_TMP=$(mktemp "$(dirname "$output")/.$(basename "$output").tmp.XXXXXXXX") + chmod 600 "$OUTPUT_TMP" + tar -C "$WORK_DIR" -czf - database.dump phase.env compose-config.yaml manifest.txt checksums.txt | + openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 -out "$OUTPUT_TMP" -pass fd:3 3<<<"$PASSWORD" + ln "$OUTPUT_TMP" "$output" || fail "refusing to overwrite $output" + rm "$OUTPUT_TMP" + OUTPUT_TMP= + printf 'Backup created: %s\nVerify it before copying off-host: ./phase-admin verify %s\n' "$output" "$output" +} + +verify() { + local archive=${1:-} + [[ -n "$archive" ]] || fail 'verify requires an archive path' + require_archive_tools + read_password false + extract_and_verify "$archive" + printf 'Backup is valid.\n' + cat "$WORK_DIR/manifest.txt" +} + +restore() { + local archive=${1:-} assume_yes=${2:-} + [[ -n "$archive" ]] || fail 'restore requires an archive path' + [[ "$assume_yes" == "" || "$assume_yes" == "--yes" ]] || fail "unknown option: $assume_yes" + require_compose + read_password false + extract_and_verify "$archive" + + if [[ -f "$ROOT_DIR/.env" ]]; then + local key current archived + for key in DATABASE_NAME DATABASE_USER DATABASE_PASSWORD; do + current=$(env_value "$ROOT_DIR/.env" "$key") + archived=$(env_value "$WORK_DIR/phase.env" "$key") + [[ "$current" == "$archived" ]] || fail "current $key differs from the backup; restore cross-instance backups with no .env and an empty phase-postgres-data volume" + done + fi + + printf 'Restore source:\n' + sed 's/^/ /' "$WORK_DIR/manifest.txt" + printf 'Restore target:\n database: postgres service in %s\n environment: %s/.env\n' "$ROOT_DIR" "$ROOT_DIR" + + if [[ "$assume_yes" != "--yes" ]]; then + [[ -t 0 ]] || fail 'restore requires --yes when running non-interactively' + printf 'Type "restore" to replace the current database and .env: ' >&2 + local answer + read -r answer + [[ "$answer" == restore ]] || fail 'restore cancelled' + fi + + cd "$ROOT_DIR" + local env_backup= + if [[ -f .env ]]; then + env_backup=$(basename "$(mktemp "$ROOT_DIR/.env.before-restore.$(date -u +%Y%m%dT%H%M%SZ).XXXXXXXX")") + cp .env "$env_backup" + ROLLBACK_ENV="$ROOT_DIR/$env_backup" + docker compose stop nginx frontend backend worker migrations + fi + cp "$WORK_DIR/phase.env" .env + if [[ -z "$env_backup" ]]; then + docker compose stop nginx frontend backend worker migrations + fi + docker compose up -d postgres + docker compose exec -T postgres sh -c \ + 'for i in $(seq 1 30); do pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0; sleep 2; done; exit 1' + { + printf 'DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public;\n' + cat "$WORK_DIR/database.dump" + } | docker compose exec -T postgres sh -c \ + 'psql --single-transaction --set ON_ERROR_STOP=on --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' + ROLLBACK_ENV= + RESTORE_COMMITTED=true + docker compose run --rm migrations + docker compose up -d + RESTORE_COMMITTED=false + if [[ -n "$env_backup" ]]; then + printf 'Restore complete. Previous environment saved to %s/%s.\n' "$ROOT_DIR" "$env_backup" + else + printf 'Restore complete.\n' + fi +} + +case ${1:-} in + backup) + shift + [[ ${1:-} != -h && ${1:-} != --help ]] || { backup_usage; exit; } + if [[ ${1:-} == --output ]]; then + [[ $# -eq 2 ]] || fail 'backup --output requires one path' + backup "$2" + else + [[ $# -le 1 ]] || fail 'backup accepts --output OUTPUT or one positional output path' + backup "$@" + fi + ;; + verify) shift; [[ ${1:-} != -h && ${1:-} != --help ]] || { verify_usage; exit; }; [[ $# -eq 1 ]] || fail 'verify requires one archive path'; verify "$@" ;; + restore) shift; [[ ${1:-} != -h && ${1:-} != --help ]] || { restore_usage; exit; }; [[ $# -ge 1 && $# -le 2 ]] || fail 'restore requires an archive path and optional --yes'; restore "$@" ;; + -h|--help|help|'') usage ;; + *) fail "unknown command: $1" ;; +esac diff --git a/tests/phase-admin-compose.yml b/tests/phase-admin-compose.yml new file mode 100644 index 000000000..d14f45a10 --- /dev/null +++ b/tests/phase-admin-compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:15.4-alpine3.17 + env_file: .env + environment: + POSTGRES_DB: ${DATABASE_NAME} + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME}"] + interval: 1s + timeout: 2s + retries: 30 + backend: + image: postgres:15.4-alpine3.17 + container_name: phase-backend + command: ["sleep", "infinity"] + frontend: + image: postgres:15.4-alpine3.17 + container_name: phase-frontend + command: ["sleep", "infinity"] + worker: + image: postgres:15.4-alpine3.17 + command: ["sleep", "infinity"] + nginx: + image: postgres:15.4-alpine3.17 + command: ["sleep", "infinity"] + migrations: + image: postgres:15.4-alpine3.17 + command: ["true"] diff --git a/tests/test-phase-admin-compose.sh b/tests/test-phase-admin-compose.sh new file mode 100755 index 000000000..a44d8a199 --- /dev/null +++ b/tests/test-phase-admin-compose.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SANDBOX=$(mktemp -d "${TMPDIR:-/tmp}/phase-admin-compose.XXXXXXXX") + +cleanup() { + (cd "$SANDBOX" && docker compose down -v >/dev/null 2>&1) || true + rm -rf "$SANDBOX" +} +trap cleanup EXIT + +cp "$ROOT_DIR/phase-admin" "$SANDBOX/phase-admin" +cp "$ROOT_DIR/tests/phase-admin-compose.yml" "$SANDBOX/docker-compose.yml" +chmod +x "$SANDBOX/phase-admin" +printf 'NEXTAUTH_SECRET=integration-nextauth\nSECRET_KEY=integration-django\nSERVER_SECRET=integration-server-secret\nDATABASE_NAME=phase\nDATABASE_USER=phase\nDATABASE_PASSWORD=phase-password\n' >"$SANDBOX/.env" + +cd "$SANDBOX" +export PHASE_BACKUP_PASSWORD=integration-password +docker compose up -d postgres backend frontend worker nginx +docker compose exec -T postgres sh -c \ + 'until pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do sleep 1; done' +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U phase -d phase <<'SQL' +CREATE TABLE django_migrations (id integer, app text, name text, applied timestamptz); +INSERT INTO django_migrations VALUES (1, 'api', '0001_initial', now()); +CREATE TABLE backed_up (value text); +INSERT INTO backed_up VALUES ('before'); +SQL + +./phase-admin backup --output backup.tar.enc +docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U phase -d phase <<'SQL' +UPDATE backed_up SET value = 'after'; +CREATE TABLE target_only (id integer); +SQL +./phase-admin restore backup.tar.enc --yes + +[[ $(docker compose exec -T postgres psql -At -U phase -d phase -c 'SELECT value FROM backed_up') == before ]] +[[ $(docker compose exec -T postgres psql -At -U phase -d phase -c "SELECT to_regclass('public.target_only') IS NULL") == t ]] +printf 'phase-admin PostgreSQL/Compose integration test passed\n' diff --git a/tests/test-phase-admin.sh b/tests/test-phase-admin.sh new file mode 100755 index 000000000..36a8099ec --- /dev/null +++ b/tests/test-phase-admin.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SANDBOX=$(mktemp -d "${TMPDIR:-/tmp}/phase-admin-test.XXXXXXXX") +trap 'rm -rf "$SANDBOX"' EXIT + +cp "$ROOT_DIR/phase-admin" "$SANDBOX/phase-admin" +mkdir "$SANDBOX/bin" +printf 'NEXTAUTH_SECRET=nextauth\nSECRET_KEY=django\nSERVER_SECRET=original\nDATABASE_NAME=phase\nDATABASE_USER=phase\nDATABASE_PASSWORD=phase-password\n' >"$SANDBOX/.env" + +cat >"$SANDBOX/bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ ${1:-} == inspect ]]; then + printf 'phasehq/backend:test\n' + exit +fi +[[ ${1:-} == compose ]] || exit 1 +shift +case ${1:-} in + version) exit ;; + ps) printf 'postgres\n' ;; + config) printf 'services:\n postgres: {}\n' ;; + stop) [[ -z ${STOP_MARKER:-} ]] || : >"$STOP_MARKER" ;; + up) exit ;; + run) [[ ${FAIL_MIGRATIONS:-} != 1 ]] ;; + exec) + case "$*" in + *pg_dump*) printf '%s\n' 'CREATE TABLE restored (id integer);' ;; + *'count(*)'*) printf '42\n' ;; + *'ORDER BY applied'*) printf 'api:0042_test\n' ;; + *pg_isready*) exit ;; + *'psql --single-transaction'*) cat >/dev/null; [[ ${FAIL_RESTORE:-} != 1 ]] ;; + *) exit 1 ;; + esac + ;; + *) exit 1 ;; +esac +EOF +chmod +x "$SANDBOX/phase-admin" "$SANDBOX/bin/docker" + +cd "$SANDBOX" +export PATH="$SANDBOX/bin:$PATH" +export PHASE_BACKUP_PASSWORD=test-password + +./phase-admin backup --output backup.tar.enc +mv bin/docker bin/docker.disabled +./phase-admin verify backup.tar.enc | grep -q 'latest_migration=api:0042_test' +mv bin/docker.disabled bin/docker +printf 'NEXTAUTH_SECRET=nextauth\nSECRET_KEY=django\nSERVER_SECRET=mutated\nDATABASE_NAME=phase\nDATABASE_USER=phase\nDATABASE_PASSWORD=phase-password\n' >.env + +export FAIL_RESTORE=1 +if ./phase-admin restore backup.tar.enc --yes 2>restore-error.log; then + printf 'restore unexpectedly succeeded\n' >&2 + exit 1 +fi +unset FAIL_RESTORE +grep -q '^SERVER_SECRET=mutated$' .env +grep -q 'restored .*\.env' restore-error.log + +export FAIL_MIGRATIONS=1 +if ./phase-admin restore backup.tar.enc --yes 2>migration-error.log; then + printf 'restore with failed migrations unexpectedly succeeded\n' >&2 + exit 1 +fi +unset FAIL_MIGRATIONS +grep -q '^SERVER_SECRET=original$' .env +grep -q 'database was restored' migration-error.log + +./phase-admin restore backup.tar.enc --yes +grep -q '^SERVER_SECRET=original$' .env +grep -q '^SERVER_SECRET=mutated$' .env.before-restore.* + +rm .env +export STOP_MARKER="$SANDBOX/stop-called" +./phase-admin restore backup.tar.enc --yes +grep -q '^SERVER_SECRET=original$' .env +[[ -f "$STOP_MARKER" ]] + +printf 'phase-admin backup/verify/restore test passed\n'