#!/usr/bin/env bash set -Eeuo pipefail usage() { cat <<'EOF' Usage: backup.sh [output-directory] Environment: DEPLOY_ROOT Deployment directory. Defaults to ~/guilan-ace-deployment. Creates a timestamped .tar.gz archive containing: - PostgreSQL dump from the db service - media files from the Django media volume - deployment, backend, and frontend .env files when present EOF } log() { printf '[backup] %s\n' "$*" } fail() { printf '[backup] %s\n' "$*" >&2 exit 1 } require_file() { local path="$1" [[ -f "$path" ]] || fail "Required file not found: $path" } compose() { docker compose --env-file "$DEPLOY_ENV" -f "$DEPLOY_ROOT/docker-compose.yml" "$@" } wait_for_db() { compose exec -T db sh -c ' set -eu : "${DB_USER:?DB_USER is missing}" : "${DB_NAME:?DB_NAME is missing}" until pg_isready --username="$DB_USER" --dbname="$DB_NAME" --host=127.0.0.1; do sleep 1 done ' >/dev/null } copy_env_if_exists() { local source_path="$1" local target_path="$2" if [[ -f "$source_path" ]]; then cp "$source_path" "$target_path" fi } env_value() { local key="$1" local file="$2" awk -v key="$key" ' $0 ~ "^[[:space:]]*" key "=" { sub(/^[^=]*=/, "") print exit } ' "$file" } set_env_value() { local key="$1" local value="$2" local file="$3" local tmp_path tmp_path="$(mktemp)" awk -v key="$key" -v value="$value" ' $0 ~ "^[[:space:]]*" key "=" { print key "=" value found = 1 next } { print } END { if (!found) { print key "=" value } } ' "$file" > "$tmp_path" mv "$tmp_path" "$file" chmod 600 "$file" || true } sync_postgres_env_aliases() { local db_name db_user db_password db_name="$(env_value DB_NAME "$BACKEND_ENV")" db_user="$(env_value DB_USER "$BACKEND_ENV")" db_password="$(env_value DB_PASSWORD "$BACKEND_ENV")" [[ -n "$db_name" ]] || fail "DB_NAME is missing or empty in $BACKEND_ENV" [[ -n "$db_user" ]] || fail "DB_USER is missing or empty in $BACKEND_ENV" [[ -n "$db_password" ]] || fail "DB_PASSWORD is missing or empty in $BACKEND_ENV" set_env_value POSTGRES_DB "$db_name" "$BACKEND_ENV" set_env_value POSTGRES_USER "$db_user" "$BACKEND_ENV" set_env_value POSTGRES_PASSWORD "$db_password" "$BACKEND_ENV" } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/guilan-ace-deployment}" OUTPUT_DIR="${1:-$DEPLOY_ROOT/backups}" BACKEND_ENV="$DEPLOY_ROOT/backend/guilan-ace-backend/.env" FRONTEND_ENV="$DEPLOY_ROOT/frontend/guilan-ace-frontend/.env" DEPLOY_ENV="$DEPLOY_ROOT/.env" require_file "$DEPLOY_ROOT/docker-compose.yml" require_file "$DEPLOY_ENV" require_file "$BACKEND_ENV" log "Ensuring PostgreSQL environment aliases exist" sync_postgres_env_aliases mkdir -p "$OUTPUT_DIR" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT TIMESTAMP="$(date -u +'%Y%m%d-%H%M%S')" ARCHIVE_NAME="guilan-ace-backup-$TIMESTAMP.tar.gz" ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME" mkdir -p "$WORK_DIR/env" log "Checking Docker Compose configuration" compose config -q log "Starting database service if needed" compose up -d db wait_for_db log "Dumping database from db service" compose exec -T db sh -c ' set -eu : "${DB_USER:?DB_USER is missing}" : "${DB_NAME:?DB_NAME is missing}" pg_dump \ --username="$DB_USER" \ --dbname="$DB_NAME" \ --format=plain \ --clean \ --if-exists \ --no-owner \ --no-privileges ' > "$WORK_DIR/database.sql" log "Archiving media files from Django media volume" if compose ps --status running --services | grep -qx 'web'; then compose exec -T web sh -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz" else log "Web service is not running; using a temporary container for media volume" compose run --rm --no-deps --entrypoint sh web -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz" fi log "Copying environment files" copy_env_if_exists "$DEPLOY_ENV" "$WORK_DIR/env/deployment.env" copy_env_if_exists "$BACKEND_ENV" "$WORK_DIR/env/backend.env" copy_env_if_exists "$FRONTEND_ENV" "$WORK_DIR/env/frontend.env" cat > "$WORK_DIR/manifest.txt" <