#!/usr/bin/env bash # Create a stopped-instance backup. Business data and recovery credentials are # deliberately written to different root-owned destinations. set -euo pipefail INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}" ENV_FILE="${ENV_FILE:?ENV_FILE required}" KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}" BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}" RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}" SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" [ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; } umask 077 validate_root_secret_file() { local label="$1" path="$2" metadata [[ "$path" = /* ]] || { echo "$label must be an absolute path" >&2; exit 1; } [ ! -L "$path" ] || { echo "$label must not be a symlink: $path" >&2; exit 1; } [ -f "$path" ] || { echo "$label must be a regular file: $path" >&2; exit 1; } metadata="$(stat -c '%u:%g:%a' -- "$path")" [ "$metadata" = "0:0:600" ] || { echo "$label must be root:root mode 0600; found $metadata" >&2 exit 1 } } validate_root_secret_file "ENV_FILE" "$ENV_FILE" validate_root_secret_file "KEYRING_FILE" "$KEYRING_FILE" state="$(systemctl show --property=ActiveState --value "$SERVICE_UNIT")" case "$state" in inactive|failed) ;; *) echo "$SERVICE_UNIT must be stopped; state=$state" >&2; exit 1;; esac set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a : "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}" : "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}" [ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; } [ -d "$SKILL_STORE_ROOT" ] || { echo "skill store root missing" >&2; exit 1; } install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR" business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")" recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")" workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")" secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")" skill_root="$(realpath -m "$SKILL_STORE_ROOT")" paths_overlap() { local left="$1" right="$2" [ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]] } for pair in \ "$business_root|$recovery_root" \ "$business_root|$workspace_root" \ "$recovery_root|$workspace_root" \ "$recovery_root|$secret_root"; do left="${pair%%|*}"; right="${pair#*|}" if paths_overlap "$left" "$right"; then echo "backup destinations must not overlap each other or live state: $left <> $right" >&2 exit 1 fi done if paths_overlap "$business_root" "$skill_root" || paths_overlap "$recovery_root" "$skill_root" || paths_overlap "$workspace_root" "$skill_root"; then echo "backup destinations, workspace and skill store must not overlap: $skill_root" >&2 exit 1 fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" business="$business_root/$INSTANCE_ID-$stamp" recovery="$recovery_root/$INSTANCE_ID-$stamp" install -d -o root -g root -m 0700 "$business" "$recovery" pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL" tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" . tar --create --file="$business/agent-skills.tar" --directory="$SKILL_STORE_ROOT" . cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json" cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env" chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env" ( cd "$business" sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS ) ( cd "$recovery" sha256sum secret-keyring.json platform.env > SHA256SUMS ) echo "business backup: $business" echo "separate recovery backup: $recovery"