From 4fa82a3a04ac85ee24880dcd0373932ee7a37275 Mon Sep 17 00:00:00 2001 From: howailok1120 Date: Thu, 23 Jul 2026 03:21:51 +0800 Subject: [PATCH 1/3] feat: add self-hosted backup and restore utility --- .gitignore | 2 + README.md | 1 + docs/self-hosted-backup.md | 36 +++++++ phase-admin | 197 +++++++++++++++++++++++++++++++++++++ tests/test-phase-admin.sh | 52 ++++++++++ 5 files changed, 288 insertions(+) create mode 100644 docs/self-hosted-backup.md create mode 100755 phase-admin create mode 100755 tests/test-phase-admin.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..c34742a4b --- /dev/null +++ b/docs/self-hosted-backup.md @@ -0,0 +1,36 @@ +# Back up and restore a self-hosted Phase instance + +`phase-admin` creates a complete backup of a 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 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 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 + +Use the same Console checkout and Docker Compose deployment that created the backup. For migration to a new host, start with an empty `phase-postgres-data` volume and copy the encrypted archive into the checkout. + +```bash +./phase-admin restore phase-backup.tar.enc +``` + +Restore verifies every archived file before making changes, stops services that can write to PostgreSQL, saves the current `.env` as `.env.before-restore.`, and restores the database in one transaction. It then starts the full deployment so the normal migration service can apply migrations required by a newer Console version. + +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..27bb45a04 --- /dev/null +++ b/phase-admin @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +WORK_DIR= +OUTPUT_TMP= +ROLLBACK_ENV= + +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 + 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] + ./phase-admin verify ARCHIVE + ./phase-admin restore ARCHIVE [--yes] + +Creates, verifies, or restores a password-encrypted full-instance backup for +the repository's Docker Compose deployment. Set PHASE_BACKUP_PASSWORD for +non-interactive use; otherwise the password is read from the terminal. +EOF +} + +require_tools() { + local tool + for tool in docker openssl tar; do + command -v "$tool" >/dev/null || fail "$tool is required" + done + 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}' +} + +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 -tf "$tarball" | sort) + expected=$'checksums.txt\ncompose-config.yaml\ndatabase.dump\nmanifest.txt\nphase.env' + [[ "$listing" == "$expected" ]] || fail 'archive contains unexpected or missing files' + tar -C "$WORK_DIR" -xf "$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 +} + +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" + [[ ! -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_tools + 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=custom --clean --if-exists --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 image latest_migration migration_count + image=$(docker inspect phase-backend --format '{{.Config.Image}}@{{.Image}}' 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="${output}.tmp.$$" + tar -C "$WORK_DIR" -cf - 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" + chmod 600 "$OUTPUT_TMP" + mv "$OUTPUT_TMP" "$output" + OUTPUT_TMP= + printf 'Backup created: %s\n' "$output" +} + +verify() { + local archive=${1:-} + [[ -n "$archive" ]] || fail 'verify requires an archive path' + require_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" + [[ -f "$ROOT_DIR/.env" ]] || fail "$ROOT_DIR/.env does not exist" + require_tools + read_password false + extract_and_verify "$archive" + + if [[ "$assume_yes" != "--yes" ]]; then + [[ -t 0 ]] || fail 'restore requires --yes when running non-interactively' + printf 'This replaces the Phase database and .env. Type "restore" to continue: ' >&2 + local answer + read -r answer + [[ "$answer" == restore ]] || fail 'restore cancelled' + fi + + cd "$ROOT_DIR" + local env_backup=".env.before-restore.$(date -u +%Y%m%dT%H%M%SZ)" + cp .env "$env_backup" + ROLLBACK_ENV="$ROOT_DIR/$env_backup" + docker compose stop nginx frontend backend worker migrations + cp "$WORK_DIR/phase.env" .env + 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' + docker compose exec -T postgres sh -c \ + 'pg_restore --clean --if-exists --no-owner --no-privileges --single-transaction --exit-on-error --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ + <"$WORK_DIR/database.dump" + ROLLBACK_ENV= + docker compose run --rm migrations + docker compose up -d + printf 'Restore complete. Previous environment saved to %s/%s.\n' "$ROOT_DIR" "$env_backup" +} + +case ${1:-} in + backup) shift; [[ $# -le 1 ]] || fail 'backup accepts at most one output path'; backup "$@" ;; + verify) shift; [[ $# -eq 1 ]] || fail 'verify requires one archive path'; verify "$@" ;; + restore) shift; [[ $# -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/test-phase-admin.sh b/tests/test-phase-admin.sh new file mode 100755 index 000000000..d58d6b767 --- /dev/null +++ b/tests/test-phase-admin.sh @@ -0,0 +1,52 @@ +#!/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 'SERVER_SECRET=original\nDATABASE_NAME=phase\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|up|run) exit ;; + exec) + case "$*" in + *pg_dump*) printf 'fake PostgreSQL custom dump' ;; + *'count(*)'*) printf '42\n' ;; + *'ORDER BY applied'*) printf 'api:0042_test\n' ;; + *pg_isready*) exit ;; + *pg_restore*) cat >/dev/null ;; + *) 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 backup.tar.enc +./phase-admin verify backup.tar.enc | grep -q 'latest_migration=api:0042_test' +printf 'SERVER_SECRET=mutated\n' >.env +./phase-admin restore backup.tar.enc --yes +grep -q '^SERVER_SECRET=original$' .env +grep -q '^SERVER_SECRET=mutated$' .env.before-restore.* + +printf 'phase-admin backup/verify/restore test passed\n' From 541efbbbb47bfe9827033914a6be8170bcf66a12 Mon Sep 17 00:00:00 2001 From: howailok1120 Date: Thu, 23 Jul 2026 03:42:38 +0800 Subject: [PATCH 2/3] fix: harden backup restore workflow --- .github/workflows/phase-admin.yml | 20 ++++ docs/self-hosted-backup.md | 27 ++++- phase-admin | 159 ++++++++++++++++++++++++------ tests/phase-admin-compose.yml | 30 ++++++ tests/test-phase-admin-compose.sh | 39 ++++++++ tests/test-phase-admin.sh | 38 +++++-- 6 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/phase-admin.yml create mode 100644 tests/phase-admin-compose.yml create mode 100755 tests/test-phase-admin-compose.sh 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/docs/self-hosted-backup.md b/docs/self-hosted-backup.md index c34742a4b..125dbc64d 100644 --- a/docs/self-hosted-backup.md +++ b/docs/self-hosted-backup.md @@ -1,33 +1,50 @@ # Back up and restore a self-hosted Phase instance -`phase-admin` creates a complete backup of a 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. +`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 phase-backup.tar.enc +./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 phase-backup.tar.enc +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 -Use the same Console checkout and Docker Compose deployment that created the backup. For migration to a new host, start with an empty `phase-postgres-data` volume and copy the encrypted archive into the checkout. +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 ``` -Restore verifies every archived file before making changes, stops services that can write to PostgreSQL, saves the current `.env` as `.env.before-restore.`, and restores the database in one transaction. It then starts the full deployment so the normal migration service can apply migrations required by a newer Console version. +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. diff --git a/phase-admin b/phase-admin index 27bb45a04..cf1a18677 100755 --- a/phase-admin +++ b/phase-admin @@ -5,6 +5,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) WORK_DIR= OUTPUT_TMP= ROLLBACK_ENV= +RESTORE_COMMITTED=false cleanup() { local status=$? @@ -12,6 +13,9 @@ cleanup() { 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" @@ -27,21 +31,55 @@ fail() { usage() { cat <<'EOF' Usage: - ./phase-admin backup [OUTPUT] + ./phase-admin backup [--output OUTPUT] ./phase-admin verify ARCHIVE ./phase-admin restore ARCHIVE [--yes] -Creates, verifies, or restores a password-encrypted full-instance backup for -the repository's Docker Compose deployment. Set PHASE_BACKUP_PASSWORD for +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_tools() { +require_archive_tools() { local tool - for tool in docker openssl tar; do + 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' } @@ -68,6 +106,17 @@ 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" @@ -80,10 +129,11 @@ extract_and_verify() { 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 -tf "$tarball" | sort) + 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' - tar -C "$WORK_DIR" -xf "$tarball" + [[ -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 @@ -92,29 +142,36 @@ extract_and_verify() { 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_tools + 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=custom --clean --if-exists --no-owner --no-privileges --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ + '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 image latest_migration migration_count - image=$(docker inspect phase-backend --format '{{.Config.Image}}@{{.Image}}' 2>/dev/null || printf 'unknown') + 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 \ @@ -123,9 +180,13 @@ backup() { cat >"$WORK_DIR/manifest.txt" <"$WORK_DIR/checksums.txt" @@ -134,19 +195,20 @@ EOF printf '%s %s\n' "$(sha256 "$WORK_DIR/$file")" "$file" >>"$WORK_DIR/checksums.txt" done - OUTPUT_TMP="${output}.tmp.$$" - tar -C "$WORK_DIR" -cf - 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" + OUTPUT_TMP=$(mktemp "$(dirname "$output")/.$(basename "$output").tmp.XXXXXXXX") chmod 600 "$OUTPUT_TMP" - mv "$OUTPUT_TMP" "$output" + 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\n' "$output" + 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_tools + require_archive_tools read_password false extract_and_verify "$archive" printf 'Backup is valid.\n' @@ -157,41 +219,74 @@ 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" - [[ -f "$ROOT_DIR/.env" ]] || fail "$ROOT_DIR/.env does not exist" - require_tools + 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 'This replaces the Phase database and .env. Type "restore" to continue: ' >&2 + 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=".env.before-restore.$(date -u +%Y%m%dT%H%M%SZ)" - cp .env "$env_backup" - ROLLBACK_ENV="$ROOT_DIR/$env_backup" - docker compose stop nginx frontend backend worker migrations + 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 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' - docker compose exec -T postgres sh -c \ - 'pg_restore --clean --if-exists --no-owner --no-privileges --single-transaction --exit-on-error --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ - <"$WORK_DIR/database.dump" + { + 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 - printf 'Restore complete. Previous environment saved to %s/%s.\n' "$ROOT_DIR" "$env_backup" + 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; [[ $# -le 1 ]] || fail 'backup accepts at most one output path'; backup "$@" ;; - verify) shift; [[ $# -eq 1 ]] || fail 'verify requires one archive path'; verify "$@" ;; - restore) shift; [[ $# -ge 1 && $# -le 2 ]] || fail 'restore requires an archive path and optional --yes'; restore "$@" ;; + 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 index d58d6b767..4bb603450 100755 --- a/tests/test-phase-admin.sh +++ b/tests/test-phase-admin.sh @@ -7,7 +7,7 @@ trap 'rm -rf "$SANDBOX"' EXIT cp "$ROOT_DIR/phase-admin" "$SANDBOX/phase-admin" mkdir "$SANDBOX/bin" -printf 'SERVER_SECRET=original\nDATABASE_NAME=phase\n' >"$SANDBOX/.env" +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 @@ -22,14 +22,15 @@ case ${1:-} in version) exit ;; ps) printf 'postgres\n' ;; config) printf 'services:\n postgres: {}\n' ;; - stop|up|run) exit ;; + stop|up) exit ;; + run) [[ ${FAIL_MIGRATIONS:-} != 1 ]] ;; exec) case "$*" in - *pg_dump*) printf 'fake PostgreSQL custom dump' ;; + *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 ;; - *pg_restore*) cat >/dev/null ;; + *'psql --single-transaction'*) cat >/dev/null; [[ ${FAIL_RESTORE:-} != 1 ]] ;; *) exit 1 ;; esac ;; @@ -42,11 +43,36 @@ cd "$SANDBOX" export PATH="$SANDBOX/bin:$PATH" export PHASE_BACKUP_PASSWORD=test-password -./phase-admin backup backup.tar.enc +./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' -printf 'SERVER_SECRET=mutated\n' >.env +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 +./phase-admin restore backup.tar.enc --yes +grep -q '^SERVER_SECRET=original$' .env + printf 'phase-admin backup/verify/restore test passed\n' From a830c25e055683b4b6ea486738360898cd173806 Mon Sep 17 00:00:00 2001 From: howailok1120 Date: Thu, 23 Jul 2026 03:43:46 +0800 Subject: [PATCH 3/3] fix: stop writers on fresh-host restore --- phase-admin | 3 +++ tests/test-phase-admin.sh | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/phase-admin b/phase-admin index cf1a18677..a8b56672a 100755 --- a/phase-admin +++ b/phase-admin @@ -253,6 +253,9 @@ restore() { 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' diff --git a/tests/test-phase-admin.sh b/tests/test-phase-admin.sh index 4bb603450..36a8099ec 100755 --- a/tests/test-phase-admin.sh +++ b/tests/test-phase-admin.sh @@ -22,7 +22,8 @@ case ${1:-} in version) exit ;; ps) printf 'postgres\n' ;; config) printf 'services:\n postgres: {}\n' ;; - stop|up) exit ;; + stop) [[ -z ${STOP_MARKER:-} ]] || : >"$STOP_MARKER" ;; + up) exit ;; run) [[ ${FAIL_MIGRATIONS:-} != 1 ]] ;; exec) case "$*" in @@ -72,7 +73,9 @@ 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'