#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=wp13-common.sh source "${SCRIPT_DIR}/wp13-common.sh" usage() { cat <<'EOF' Usage: backup.sh --output-dir PATH --confirm-maintenance-window [options] Create a logical PostgreSQL export and a consistent cold snapshot of every Compose-managed volume. The project is restarted even if archiving fails. Options: --output-dir PATH New backup directory. --compose-file PATH Compose file. --env-file PATH Optional Compose environment file. --project-name NAME Source project (default: dataops-test). --confirm-maintenance-window Required acknowledgement of bounded downtime. --allow-degraded Permit backup when a container is not healthy. --timeout SECONDS Restart health timeout (default: 600). --help Show this help. EOF } ROOT="$(wp13_default_root)" OUTPUT_DIR="" COMPOSE_FILE="" ENV_FILE="" PROJECT_NAME="dataops-test" CONFIRM_MAINTENANCE=0 ALLOW_DEGRADED=0 TIMEOUT_SECONDS=600 while [[ $# -gt 0 ]]; do case "$1" in --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --compose-file) COMPOSE_FILE="$2"; shift 2 ;; --env-file) ENV_FILE="$2"; shift 2 ;; --project-name) PROJECT_NAME="$2"; shift 2 ;; --confirm-maintenance-window) CONFIRM_MAINTENANCE=1; shift ;; --allow-degraded) ALLOW_DEGRADED=1; shift ;; --timeout) TIMEOUT_SECONDS="$2"; shift 2 ;; --help|-h) usage; exit 0 ;; *) wp13_die "unknown argument: $1" ;; esac done [[ -n "${OUTPUT_DIR}" ]] || wp13_die "--output-dir is required" [[ "${CONFIRM_MAINTENANCE}" -eq 1 ]] \ || wp13_die "--confirm-maintenance-window is required" COMPOSE_FILE="${COMPOSE_FILE:-${ROOT}/deploy/docker/docker-compose.yml}" [[ -f "${COMPOSE_FILE}" ]] || wp13_die "compose file not found: ${COMPOSE_FILE}" if [[ -e "${OUTPUT_DIR}" && -n "$(find "${OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then wp13_die "backup destination must be empty: ${OUTPUT_DIR}" fi mkdir -p "${OUTPUT_DIR}/volumes" OUTPUT_DIR="$(cd "${OUTPUT_DIR}" && pwd)" wp13_require_command docker [[ -n "$(wp13_project_running_ids "${PROJECT_NAME}")" ]] \ || wp13_die "source project is not running: ${PROJECT_NAME}" if [[ "${ALLOW_DEGRADED}" -eq 0 ]]; then wp13_wait_for_project "${PROJECT_NAME}" 5 fi STARTED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" wp13_log "creating logical PostgreSQL export" wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ exec -T postgres pg_dumpall -U dataops \ >"${OUTPUT_DIR}/postgres-all.sql" wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ exec -T backend alembic -c alembic.ini current \ >"${OUTPUT_DIR}/alembic-current.txt" wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ config --images | LC_ALL=C sort -u \ >"${OUTPUT_DIR}/images.txt" VOLUME_NAMES="$( docker volume ls \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Name}}' \ | LC_ALL=C sort )" [[ -n "${VOLUME_NAMES}" ]] || wp13_die "no Compose-managed volumes found" PROJECT_STOPPED=0 restart_source() { if [[ "${PROJECT_STOPPED}" -eq 1 ]]; then wp13_log "restarting source project ${PROJECT_NAME}" if wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" start >/dev/null; then wp13_wait_for_project "${PROJECT_NAME}" "${TIMEOUT_SECONDS}" || true fi fi } trap restart_source EXIT wp13_log "stopping source project for consistent volume snapshots" wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" stop >/dev/null PROJECT_STOPPED=1 { printf 'format=wp13-backup-v1\n' printf 'source_project=%s\n' "${PROJECT_NAME}" printf 'started_at_utc=%s\n' "${STARTED_AT}" printf 'compose_sha256=%s\n' "$(wp13_sha256_file "${COMPOSE_FILE}")" printf 'alembic_current=%s\n' "$(awk 'NF {print $1; exit}' "${OUTPUT_DIR}/alembic-current.txt")" } >"${OUTPUT_DIR}/backup-manifest.txt" while IFS= read -r volume_name; do [[ -n "${volume_name}" ]] || continue logical_name="$( docker volume inspect \ -f '{{index .Labels "com.docker.compose.volume"}}' \ "${volume_name}" )" [[ -n "${logical_name}" && "${logical_name}" != "" ]] \ || wp13_die "volume is missing Compose logical label: ${volume_name}" archive_name="$(wp13_safe_archive_name "${logical_name}").tar.gz" wp13_log "archiving volume ${logical_name}" docker run --rm \ -v "${volume_name}:/volume:ro" \ -v "${OUTPUT_DIR}/volumes:/backup" \ postgres:16-alpine \ sh -c "cd /volume && tar -czf '/backup/${archive_name}' ." printf 'volume=%s|archive=volumes/%s|source_name=%s\n' \ "${logical_name}" "${archive_name}" "${volume_name}" \ >>"${OUTPUT_DIR}/backup-manifest.txt" done <<<"${VOLUME_NAMES}" wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" start >/dev/null PROJECT_STOPPED=0 wp13_wait_for_project "${PROJECT_NAME}" "${TIMEOUT_SECONDS}" printf 'completed_at_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ >>"${OUTPUT_DIR}/backup-manifest.txt" wp13_write_checksums "${OUTPUT_DIR}" wp13_log "backup completed: ${OUTPUT_DIR}"