#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # db-restore.sh – SQLite database restore for Claude Code Agent Monitor # # Usage: # ./db-restore.sh --env production --input ./backups/agent-monitor_production_20240101_120000.db # ./db-restore.sh --help # ───────────────────────────────────────────────────────────────────────────── # @author Nguyễn Ngọc Trí Vĩ set -euo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly APP_NAME="agent-monitor" readonly APP_PORT=4820 readonly DB_PATH_IN_CONTAINER="/app/data" # ── Colors & logging ─────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; } info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}ℹ${NC} $*"; } ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}✔${NC} $*"; } warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}⚠${NC} $*" >&2; } err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}✖${NC} $*" >&2; } fatal() { err "$@"; exit 1; } # ── Defaults ──────────────────────────────────────────────────────────────── ENVIRONMENT="" INPUT_FILE="" NAMESPACE="" SKIP_HEALTH_CHECK=false FORCE=false BACKUP_BEFORE_RESTORE=true # ── Usage ─────────────────────────────────────────────────────────────────── usage() { cat < --input [options] ${BOLD}Required:${NC} --env, -e Environment: dev, staging, production --input, -i Path to backup file (.db or .db.gz) ${BOLD}Options:${NC} --namespace, -n Kubernetes namespace (default: agent-monitor-) --no-backup Skip backing up current DB before restore --skip-health Skip post-restore health check --force Skip confirmation prompt --help, -h Show this help message ${BOLD}Examples:${NC} $(basename "$0") --env production --input ./backups/agent-monitor_production_20240101_120000.db $(basename "$0") --env staging --input ./backups/backup.db.gz --force EOF exit 0 } # ── Argument parsing ──────────────────────────────────────────────────────── parse_args() { [[ $# -eq 0 ]] && usage while [[ $# -gt 0 ]]; do case "$1" in --env|-e) ENVIRONMENT="$2"; shift 2 ;; --input|-i) INPUT_FILE="$2"; shift 2 ;; --namespace|-n) NAMESPACE="$2"; shift 2 ;; --no-backup) BACKUP_BEFORE_RESTORE=false; shift ;; --skip-health) SKIP_HEALTH_CHECK=true; shift ;; --force) FORCE=true; shift ;; --help|-h) usage ;; *) fatal "Unknown option: $1" ;; esac done [[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env" [[ -z "$INPUT_FILE" ]] && fatal "Missing required argument: --input" [[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}" # Validate input file exists [[ -f "$INPUT_FILE" ]] || fatal "Input file not found: ${INPUT_FILE}" } # ── Validate backup file ─────────────────────────────────────────────────── validate_input() { info "Validating input file: ${INPUT_FILE}" local restore_file="${INPUT_FILE}" # Decompress if needed if [[ "$INPUT_FILE" == *.gz ]]; then info "Decompressing gzipped backup..." restore_file="${INPUT_FILE%.gz}" if [[ -f "$restore_file" ]]; then warn "Decompressed file already exists: ${restore_file}" else gzip -dk "${INPUT_FILE}" || fatal "Failed to decompress ${INPUT_FILE}" fi fi RESTORE_FILE="$restore_file" # Validate with sqlite3 if available if command -v sqlite3 &>/dev/null; then local integrity integrity=$(sqlite3 "${RESTORE_FILE}" "PRAGMA integrity_check;" 2>/dev/null || echo "error") if [[ "$integrity" == "ok" ]]; then ok "SQLite integrity check passed" else fatal "Input file failed integrity check: ${integrity}" fi local table_count table_count=$(sqlite3 "${RESTORE_FILE}" "SELECT count(*) FROM sqlite_master WHERE type='table';" 2>/dev/null || echo "?") info "Backup contains ${table_count} tables" else warn "sqlite3 not available – skipping integrity check" # Basic file header check local header header=$(head -c 16 "${RESTORE_FILE}" | strings 2>/dev/null || echo "") if echo "$header" | grep -q "SQLite format"; then ok "File appears to be a valid SQLite database" else fatal "File does not appear to be a SQLite database" fi fi local file_size file_size=$(du -sh "${RESTORE_FILE}" 2>/dev/null | awk '{print $1}') info "Restore file size: ${file_size}" } # ── Safety confirmation ───────────────────────────────────────────────────── confirm_restore() { if [[ "$FORCE" == true ]]; then return fi echo "" warn "${BOLD}⚠ DATABASE RESTORE WARNING ⚠${NC}" echo "" echo -e " This will ${RED}${BOLD}REPLACE${NC} the current database in ${BOLD}${ENVIRONMENT}${NC}" echo -e " with the contents of: ${INPUT_FILE}" echo "" echo -e " The deployment will be ${BOLD}scaled down${NC} during restore." echo "" if [[ "$ENVIRONMENT" == "production" ]]; then echo -e " ${RED}${BOLD}THIS IS A PRODUCTION ENVIRONMENT!${NC}" echo "" read -r -p "$(echo -e "${YELLOW}Type the environment name to confirm:${NC} ")" confirm [[ "$confirm" == "$ENVIRONMENT" ]] || fatal "Restore cancelled. You typed '${confirm}', expected '${ENVIRONMENT}'." else read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm [[ "$confirm" == "yes" ]] || fatal "Restore cancelled." fi } # ── Get deployment info ───────────────────────────────────────────────────── get_deployment_info() { info "Getting deployment info..." DEPLOYMENT_NAME=$(kubectl get deployment -n "${NAMESPACE}" \ -l "app.kubernetes.io/name=${APP_NAME}" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") if [[ -z "$DEPLOYMENT_NAME" ]]; then fatal "No deployment found for ${APP_NAME} in ${NAMESPACE}" fi ORIGINAL_REPLICAS=$(kubectl get deployment "${DEPLOYMENT_NAME}" -n "${NAMESPACE}" \ -o jsonpath='{.spec.replicas}' 2>/dev/null || echo "1") info "Deployment: ${DEPLOYMENT_NAME} (${ORIGINAL_REPLICAS} replicas)" } # ── Backup current DB before restore ─────────────────────────────────────── backup_current() { if [[ "$BACKUP_BEFORE_RESTORE" == false ]]; then info "Skipping pre-restore backup (--no-backup)" return fi info "Backing up current database before restore..." if [[ -x "${SCRIPT_DIR}/db-backup.sh" ]]; then local backup_dir="${SCRIPT_DIR}/../../data/pre-restore-backups" "${SCRIPT_DIR}/db-backup.sh" \ --env "${ENVIRONMENT}" \ --output "${backup_dir}" \ --namespace "${NAMESPACE}" \ --no-compress \ && ok "Pre-restore backup created in ${backup_dir}" \ || warn "Pre-restore backup failed – proceeding anyway" else warn "db-backup.sh not found – skipping pre-restore backup" fi } # ── Scale down deployment ─────────────────────────────────────────────────── scale_down() { info "Scaling down deployment to 0 replicas..." kubectl scale deployment "${DEPLOYMENT_NAME}" \ --replicas=0 \ -n "${NAMESPACE}" \ || fatal "Failed to scale down deployment" # Wait for all pods to terminate info "Waiting for pods to terminate..." local wait_count=0 while [[ $wait_count -lt 60 ]]; do local running running=$(kubectl get pods -n "${NAMESPACE}" \ -l "app.kubernetes.io/name=${APP_NAME}" \ --field-selector=status.phase=Running \ --no-headers 2>/dev/null | wc -l | tr -d ' ') if [[ "$running" == "0" ]]; then ok "All pods terminated" return fi wait_count=$((wait_count + 1)) sleep 2 done warn "Pods did not terminate within timeout" } # ── Restore database ─────────────────────────────────────────────────────── restore_database() { info "Restoring database..." # We need a temporary pod to access the PVC # Create a helper pod that mounts the PVC local helper_pod="${APP_NAME}-db-restore-helper" # Get PVC name local pvc_name pvc_name=$(kubectl get pvc -n "${NAMESPACE}" \ -l "app.kubernetes.io/name=${APP_NAME}" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "agent-monitor-data") info "Creating helper pod to access PVC: ${pvc_name}" kubectl apply -n "${NAMESPACE}" -f - </dev/null; then fatal "Pods did not become ready after restore!" fi if [[ -x "${SCRIPT_DIR}/health-check.sh" ]]; then local local_port=14823 kubectl port-forward "svc/${APP_NAME}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" & local pf_pid=$! sleep 3 if "${SCRIPT_DIR}/health-check.sh" --url "http://localhost:${local_port}" --retries 10 --interval 3; then ok "Post-restore health check passed" else err "Post-restore health check failed!" warn "The application may need manual investigation" fi kill "$pf_pid" 2>/dev/null || true else ok "Pods are ready" fi } # ── Main ──────────────────────────────────────────────────────────────────── main() { echo "" echo -e "${BOLD}${YELLOW}╔══════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}${YELLOW}║ Claude Code Agent Monitor – DB Restore ║${NC}" echo -e "${BOLD}${YELLOW}╚══════════════════════════════════════════════════╝${NC}" echo "" parse_args "$@" validate_input confirm_restore get_deployment_info backup_current scale_down restore_database scale_up run_health_check echo "" ok "${BOLD}Database restore complete!${NC}" echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}" echo -e " ${BOLD}Source:${NC} ${INPUT_FILE}" echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "" } main "$@"