]> begriffs open source - pg_scribe/blob - scripts/pg_scribe
Add start command
[pg_scribe] / scripts / pg_scribe
1 #!/usr/bin/env bash
2
3 # pg_scribe - Incremental SQL backup system for PostgreSQL
4 #
5 # This script provides a unified CLI for managing PostgreSQL backups
6 # using logical replication and plain SQL format.
7
8 set -euo pipefail
9
10 # Version
11 VERSION="0.1.0"
12
13 # Exit codes
14 EXIT_SUCCESS=0
15 EXIT_GENERAL_ERROR=1
16 EXIT_CONNECTION_ERROR=2
17 EXIT_SLOT_ERROR=3
18 EXIT_BACKUP_ERROR=4
19 EXIT_VALIDATION_ERROR=5
20 EXIT_WARNING=10
21
22 # Default values
23 DEFAULT_SLOT="pg_scribe"
24 DEFAULT_PORT="5432"
25 DEFAULT_HOST="localhost"
26 DEFAULT_STATUS_INTERVAL=10
27 DEFAULT_FSYNC_INTERVAL=10
28
29 # Global variables
30 ACTION=""
31 DBNAME=""
32 HOST="${PGHOST:-$DEFAULT_HOST}"
33 PORT="${PGPORT:-$DEFAULT_PORT}"
34 USERNAME="${PGUSER:-${USER:-}}"
35 FILE=""
36 SLOT="$DEFAULT_SLOT"
37 STATUS_INTERVAL="$DEFAULT_STATUS_INTERVAL"
38 FSYNC_INTERVAL="$DEFAULT_FSYNC_INTERVAL"
39 COMPRESS=""
40 CREATE_DB=0
41 BASE_BACKUP=""
42 NO_SYNC_SEQUENCES=0
43 NO_PASSWORD=0
44 FORCE_PASSWORD=0
45 VERBOSE=0
46 FORCE=0
47
48 # Color output support
49 if [[ "${PG_COLOR:-auto}" == "always" ]] || [[ "${PG_COLOR:-auto}" == "auto" && -t 2 ]]; then
50     RED='\033[0;31m'
51     GREEN='\033[0;32m'
52     YELLOW='\033[1;33m'
53     BLUE='\033[0;34m'
54     BOLD='\033[1m'
55     RESET='\033[0m'
56 else
57     RED=''
58     GREEN=''
59     YELLOW=''
60     BLUE=''
61     BOLD=''
62     RESET=''
63 fi
64
65 # Logging functions (output to stderr)
66 log_info() {
67     echo -e "${BLUE}INFO:${RESET} $*" >&2
68 }
69
70 log_success() {
71     echo -e "${GREEN}SUCCESS:${RESET} $*" >&2
72 }
73
74 log_warning() {
75     echo -e "${YELLOW}WARNING:${RESET} $*" >&2
76 }
77
78 log_error() {
79     echo -e "${RED}ERROR:${RESET} $*" >&2
80 }
81
82 log_step() {
83     echo -e "${BOLD}==>${RESET} $*" >&2
84 }
85
86 # Usage information
87 usage() {
88     cat <<EOF
89 pg_scribe - Incremental SQL backup system for PostgreSQL
90
91 Usage:
92   pg_scribe --init [OPTIONS]
93   pg_scribe --start [OPTIONS]
94   pg_scribe --full-backup [OPTIONS]
95   pg_scribe --restore [OPTIONS]
96   pg_scribe --status [OPTIONS]
97   pg_scribe --version
98   pg_scribe --help
99
100 Actions (exactly one required):
101   --init                Initialize backup system
102   --start               Start streaming incremental backups
103   --full-backup         Take a full backup
104   --restore             Restore from backups
105   --status              Check replication slot status
106   -V, --version         Print version and exit
107   -?, --help            Show this help and exit
108
109 Connection Options:
110   -d, --dbname=DBNAME   Database name (can be connection string)
111   -h, --host=HOSTNAME   Database server host (default: $DEFAULT_HOST)
112   -p, --port=PORT       Database server port (default: $DEFAULT_PORT)
113   -U, --username=NAME   Database user (default: \$PGUSER or \$USER)
114   -w, --no-password     Never prompt for password
115   -W, --password        Force password prompt
116
117 General Options:
118   -v, --verbose         Enable verbose mode
119
120 Options for --init:
121   -f, --file=DIRECTORY  Backup output directory (required)
122   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
123   --force               Skip validation and force initialization
124
125 Options for --start:
126   -f, --file=FILENAME   Output file (use '-' for stdout, required)
127   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
128   -s, --status-interval=SECS   Status update interval (default: $DEFAULT_STATUS_INTERVAL)
129   -F, --fsync-interval=SECS    Fsync interval (default: $DEFAULT_FSYNC_INTERVAL, 0 to disable)
130
131 Options for --full-backup:
132   -f, --file=DIRECTORY  Backup output directory (required)
133   -Z, --compress=METHOD Compression: gzip, lz4, zstd, or none (default: zstd:9)
134
135 Options for --restore:
136   -f, --file=DIRECTORY  Backup input directory (required)
137   -d, --dbname=DBNAME   Target database name (required)
138   -C, --create          Create target database
139   --base-backup=FILE    Specific base backup file (default: latest)
140   --no-sync-sequences   Skip sequence synchronization
141
142 Options for --status:
143   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
144   -f, --file=DIRECTORY  Backup directory to analyze (optional)
145
146 Exit Status:
147   0   Success
148   1   General error
149   2   Database connection error
150   3   Replication slot error
151   4   Backup/restore error
152   5   Invalid arguments or validation failure
153
154 Environment Variables:
155   PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PG_COLOR
156
157 Report bugs to: https://github.com/your-repo/pg_scribe/issues
158 EOF
159 }
160
161 # Parse command line arguments
162 parse_args() {
163     if [[ $# -eq 0 ]]; then
164         usage
165         exit "$EXIT_VALIDATION_ERROR"
166     fi
167
168     while [[ $# -gt 0 ]]; do
169         case "$1" in
170             --init)
171                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
172                 ACTION="init"
173                 shift
174                 ;;
175             --start)
176                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
177                 ACTION="start"
178                 shift
179                 ;;
180             --full-backup)
181                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
182                 ACTION="full-backup"
183                 shift
184                 ;;
185             --restore)
186                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
187                 ACTION="restore"
188                 shift
189                 ;;
190             --status)
191                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
192                 ACTION="status"
193                 shift
194                 ;;
195             -V|--version)
196                 echo "pg_scribe $VERSION"
197                 exit "$EXIT_SUCCESS"
198                 ;;
199             -\?|--help)
200                 usage
201                 exit "$EXIT_SUCCESS"
202                 ;;
203             -d|--dbname)
204                 DBNAME="$2"
205                 shift 2
206                 ;;
207             --dbname=*)
208                 DBNAME="${1#*=}"
209                 shift
210                 ;;
211             -h|--host)
212                 HOST="$2"
213                 shift 2
214                 ;;
215             --host=*)
216                 HOST="${1#*=}"
217                 shift
218                 ;;
219             -p|--port)
220                 PORT="$2"
221                 shift 2
222                 ;;
223             --port=*)
224                 PORT="${1#*=}"
225                 shift
226                 ;;
227             -U|--username)
228                 USERNAME="$2"
229                 shift 2
230                 ;;
231             --username=*)
232                 USERNAME="${1#*=}"
233                 shift
234                 ;;
235             -f|--file)
236                 FILE="$2"
237                 shift 2
238                 ;;
239             --file=*)
240                 FILE="${1#*=}"
241                 shift
242                 ;;
243             -S|--slot)
244                 SLOT="$2"
245                 shift 2
246                 ;;
247             --slot=*)
248                 SLOT="${1#*=}"
249                 shift
250                 ;;
251             -s|--status-interval)
252                 STATUS_INTERVAL="$2"
253                 shift 2
254                 ;;
255             --status-interval=*)
256                 STATUS_INTERVAL="${1#*=}"
257                 shift
258                 ;;
259             -F|--fsync-interval)
260                 FSYNC_INTERVAL="$2"
261                 shift 2
262                 ;;
263             --fsync-interval=*)
264                 FSYNC_INTERVAL="${1#*=}"
265                 shift
266                 ;;
267             -Z|--compress)
268                 COMPRESS="$2"
269                 shift 2
270                 ;;
271             --compress=*)
272                 COMPRESS="${1#*=}"
273                 shift
274                 ;;
275             -C|--create)
276                 CREATE_DB=1
277                 shift
278                 ;;
279             --base-backup)
280                 BASE_BACKUP="$2"
281                 shift 2
282                 ;;
283             --base-backup=*)
284                 BASE_BACKUP="${1#*=}"
285                 shift
286                 ;;
287             --no-sync-sequences)
288                 NO_SYNC_SEQUENCES=1
289                 shift
290                 ;;
291             -w|--no-password)
292                 NO_PASSWORD=1
293                 shift
294                 ;;
295             -W|--password)
296                 FORCE_PASSWORD=1
297                 shift
298                 ;;
299             -v|--verbose)
300                 VERBOSE=1
301                 shift
302                 ;;
303             --force)
304                 FORCE=1
305                 shift
306                 ;;
307             *)
308                 log_error "Unknown option: $1"
309                 usage
310                 exit "$EXIT_VALIDATION_ERROR"
311                 ;;
312         esac
313     done
314
315     # Validate action was specified
316     if [[ -z "$ACTION" ]]; then
317         log_error "No action specified"
318         usage
319         exit "$EXIT_VALIDATION_ERROR"
320     fi
321
322     # Use PGDATABASE if dbname not specified
323     if [[ -z "$DBNAME" && -n "${PGDATABASE:-}" ]]; then
324         DBNAME="$PGDATABASE"
325     fi
326 }
327
328 # Build psql connection string
329 build_psql_args() {
330     local args=()
331
332     [[ -n "$DBNAME" ]] && args+=(-d "$DBNAME")
333     [[ -n "$HOST" ]] && args+=(-h "$HOST")
334     [[ -n "$PORT" ]] && args+=(-p "$PORT")
335     [[ -n "$USERNAME" ]] && args+=(-U "$USERNAME")
336     [[ "$NO_PASSWORD" -eq 1 ]] && args+=(-w)
337     [[ "$FORCE_PASSWORD" -eq 1 ]] && args+=(-W)
338
339     printf '%s\n' "${args[@]}"
340 }
341
342 # Build pg_recvlogical connection string
343 build_pg_recvlogical_args() {
344     local args=()
345
346     [[ -n "$DBNAME" ]] && args+=(-d "$DBNAME")
347     [[ -n "$HOST" ]] && args+=(-h "$HOST")
348     [[ -n "$PORT" ]] && args+=(-p "$PORT")
349     [[ -n "$USERNAME" ]] && args+=(-U "$USERNAME")
350     [[ "$NO_PASSWORD" -eq 1 ]] && args+=(-w)
351     [[ "$FORCE_PASSWORD" -eq 1 ]] && args+=(-W)
352
353     printf '%s\n' "${args[@]}"
354 }
355
356 # Test database connection
357 test_connection() {
358     log_step "Testing database connection..."
359
360     local psql_args
361     mapfile -t psql_args < <(build_psql_args)
362
363     if ! psql "${psql_args[@]}" -c "SELECT version();" >/dev/null 2>&1; then
364         log_error "Failed to connect to database"
365         log_error "Connection details: host=$HOST port=$PORT dbname=$DBNAME user=$USERNAME"
366         exit "$EXIT_CONNECTION_ERROR"
367     fi
368
369     if [[ "$VERBOSE" -eq 1 ]]; then
370         log_success "Connected to database"
371     fi
372 }
373
374 # Execute SQL query and return result
375 query_db() {
376     local sql="$1"
377     local psql_args
378     mapfile -t psql_args < <(build_psql_args)
379     psql "${psql_args[@]}" -t -A -c "$sql" 2>&1
380 }
381
382 # Execute SQL query silently (return exit code only)
383 query_db_silent() {
384     local sql="$1"
385     local psql_args
386     mapfile -t psql_args < <(build_psql_args)
387     psql "${psql_args[@]}" -t -A -c "$sql" >/dev/null 2>&1
388 }
389
390 #
391 # --init command implementation
392 #
393 cmd_init() {
394     log_step "Initializing pg_scribe backup system"
395
396     # Validate required arguments
397     if [[ -z "$DBNAME" ]]; then
398         log_error "--init requires -d/--dbname"
399         exit "$EXIT_VALIDATION_ERROR"
400     fi
401
402     if [[ -z "$FILE" ]]; then
403         log_error "--init requires -f/--file (backup directory)"
404         exit "$EXIT_VALIDATION_ERROR"
405     fi
406
407     # Cleanup tracking for failure handling
408     local CREATED_SLOT=""
409     local CREATED_FILES=()
410
411     # Cleanup function for handling failures
412     # shellcheck disable=SC2317  # Function called via trap handler
413     cleanup_on_failure() {
414         local exit_code=$?
415
416         # Only cleanup on actual failure, not on successful exit
417         if [[ $exit_code -ne 0 && $exit_code -ne $EXIT_WARNING ]]; then
418             log_info "Cleaning up after failed initialization..."
419
420             # Drop replication slot if we created it
421             if [[ -n "$CREATED_SLOT" ]]; then
422                 log_info "Dropping replication slot '$CREATED_SLOT'..."
423                 query_db "SELECT pg_drop_replication_slot('$CREATED_SLOT');" 2>/dev/null || true
424             fi
425
426             # Remove files we created
427             for file in "${CREATED_FILES[@]}"; do
428                 if [[ -f "$file" ]]; then
429                     log_info "Removing partial file: $file"
430                     rm -f "$file" 2>/dev/null || true
431                 fi
432             done
433
434             log_info "Cleanup complete"
435         fi
436     }
437
438     # Set up cleanup trap
439     trap cleanup_on_failure EXIT INT TERM
440
441     # Test connection first
442     test_connection
443
444     # Phase 1: Validation
445     log_step "Phase 1: Validation"
446
447     local validation_failed=0
448     local has_warnings=0
449
450     # Check wal_level
451     log_info "Checking wal_level configuration..."
452     local wal_level
453     wal_level=$(query_db "SHOW wal_level;")
454     if [[ "$wal_level" != "logical" ]]; then
455         log_error "CRITICAL: wal_level is '$wal_level', must be 'logical'"
456         log_error "  Fix: Add 'wal_level = logical' to postgresql.conf and restart PostgreSQL"
457         validation_failed=1
458     else
459         if [[ "$VERBOSE" -eq 1 ]]; then
460             log_success "wal_level = logical"
461         fi
462     fi
463
464     # Check max_replication_slots
465     log_info "Checking max_replication_slots configuration..."
466     local max_slots
467     max_slots=$(query_db "SHOW max_replication_slots;")
468     if [[ "$max_slots" -lt 1 ]]; then
469         log_error "CRITICAL: max_replication_slots is $max_slots, must be >= 1"
470         log_error "  Fix: Add 'max_replication_slots = 10' to postgresql.conf and restart PostgreSQL"
471         validation_failed=1
472     else
473         if [[ "$VERBOSE" -eq 1 ]]; then
474             log_success "max_replication_slots = $max_slots"
475         fi
476     fi
477
478     # Check max_wal_senders
479     log_info "Checking max_wal_senders configuration..."
480     local max_senders
481     max_senders=$(query_db "SHOW max_wal_senders;")
482     if [[ "$max_senders" -lt 1 ]]; then
483         log_error "CRITICAL: max_wal_senders is $max_senders, must be >= 1"
484         log_error "  Fix: Add 'max_wal_senders = 10' to postgresql.conf and restart PostgreSQL"
485         validation_failed=1
486     else
487         if [[ "$VERBOSE" -eq 1 ]]; then
488             log_success "max_wal_senders = $max_senders"
489         fi
490     fi
491
492     # Check replica identity on all tables
493     log_info "Checking replica identity for all tables..."
494     local bad_tables
495     bad_tables=$(query_db "
496         SELECT n.nspname || '.' || c.relname
497         FROM pg_class c
498         JOIN pg_namespace n ON n.oid = c.relnamespace
499         WHERE c.relkind = 'r'
500           AND n.nspname NOT IN ('pg_catalog', 'information_schema')
501           AND c.relreplident IN ('d', 'n')
502           AND NOT EXISTS (
503               SELECT 1 FROM pg_index i
504               WHERE i.indrelid = c.oid AND i.indisprimary
505           )
506         ORDER BY n.nspname, c.relname;
507     ")
508
509     if [[ -n "$bad_tables" ]]; then
510         log_error "CRITICAL: The following tables lack adequate replica identity:"
511         while IFS= read -r table; do
512             log_error "  - $table"
513         done <<< "$bad_tables"
514         log_error "  Fix: Add a primary key or set replica identity:"
515         log_error "    ALTER TABLE <table> ADD PRIMARY KEY (id);"
516         log_error "    -- OR --"
517         log_error "    ALTER TABLE <table> REPLICA IDENTITY FULL;"
518         validation_failed=1
519     else
520         if [[ "$VERBOSE" -eq 1 ]]; then
521             log_success "All tables have adequate replica identity"
522         fi
523     fi
524
525     # Warning: Check for unlogged tables
526     log_info "Checking for unlogged tables..."
527     local unlogged_tables
528     unlogged_tables=$(query_db "
529         SELECT n.nspname || '.' || c.relname
530         FROM pg_class c
531         JOIN pg_namespace n ON n.oid = c.relnamespace
532         WHERE c.relkind = 'r'
533           AND c.relpersistence = 'u'
534           AND n.nspname NOT IN ('pg_catalog', 'information_schema')
535         ORDER BY n.nspname, c.relname;
536     ")
537
538     if [[ -n "$unlogged_tables" ]]; then
539         log_warning "The following unlogged tables will NOT be backed up:"
540         while IFS= read -r table; do
541             log_warning "  - $table"
542         done <<< "$unlogged_tables"
543         has_warnings=1
544     fi
545
546     # Warning: Check for large objects
547     log_info "Checking for large objects..."
548     local large_object_count
549     large_object_count=$(query_db "SELECT count(*) FROM pg_largeobject_metadata;")
550
551     if [[ "$large_object_count" -gt 0 ]]; then
552         log_warning "Database contains $large_object_count large objects"
553         log_warning "Large objects are NOT incrementally backed up (only in full backups)"
554         log_warning "Consider using BYTEA columns instead for incremental backup support"
555         has_warnings=1
556     fi
557
558     # Check if validation failed
559     if [[ "$validation_failed" -eq 1 ]]; then
560         if [[ "$FORCE" -eq 1 ]]; then
561             log_warning "Validation failed but --force specified, continuing anyway..."
562         else
563             log_error "Validation failed. Fix the CRITICAL issues above and try again."
564             log_error "Or use --force to skip validation (NOT recommended)."
565             exit "$EXIT_VALIDATION_ERROR"
566         fi
567     else
568         log_success "All validation checks passed"
569     fi
570
571     # Phase 2: Setup
572     log_step "Phase 2: Setup"
573
574     # Create backup directory
575     log_info "Checking backup directory..."
576     if [[ ! -d "$FILE" ]]; then
577         if ! mkdir -p "$FILE"; then
578             log_error "Failed to create backup directory: $FILE"
579             exit "$EXIT_BACKUP_ERROR"
580         fi
581         log_success "Created backup directory: $FILE"
582     else
583         # Directory exists - check if already initialized
584         if [[ -f "$FILE/pg_scribe_metadata.txt" ]]; then
585             log_error "Backup directory already initialized: $FILE"
586             log_error "Metadata file exists: $FILE/pg_scribe_metadata.txt"
587             log_error ""
588             log_error "This directory has already been initialized with pg_scribe."
589             log_error "To take an additional full backup, use: pg_scribe --full-backup"
590             log_error ""
591             log_error "If you want to re-initialize from scratch:"
592             log_error "  1. Stop any running backup processes"
593             log_error "  2. Drop the replication slot (or verify it's safe to reuse)"
594             log_error "  3. Remove or rename the existing backup directory"
595             exit "$EXIT_VALIDATION_ERROR"
596         fi
597
598         # Directory exists but not initialized - check if empty
599         if [[ -n "$(ls -A "$FILE" 2>/dev/null)" ]]; then
600             log_error "Backup directory is not empty: $FILE"
601             log_error "The backup directory must be empty for initialization."
602             log_error "Found existing files:"
603             # shellcheck disable=SC2012  # ls used for user-friendly display, not processing
604             ls -lh "$FILE" | head -10 >&2
605             exit "$EXIT_VALIDATION_ERROR"
606         fi
607
608         log_info "Using existing empty directory: $FILE"
609     fi
610
611     # Create wal2sql extension
612     log_info "Creating wal2sql extension..."
613     if query_db_silent "CREATE EXTENSION IF NOT EXISTS wal2sql;"; then
614         log_success "wal2sql extension created (or already exists)"
615     else
616         log_error "Failed to create wal2sql extension"
617         log_error "Ensure wal2sql.so is installed in PostgreSQL's lib directory"
618         log_error "Run: cd wal2sql && make && make install"
619         exit "$EXIT_GENERAL_ERROR"
620     fi
621
622     # Create replication slot with snapshot export
623     log_info "Creating logical replication slot '$SLOT'..."
624
625     # Check if slot already exists
626     local slot_exists
627     slot_exists=$(query_db "SELECT count(*) FROM pg_replication_slots WHERE slot_name = '$SLOT';")
628
629     if [[ "$slot_exists" -gt 0 ]]; then
630         log_error "Replication slot '$SLOT' already exists"
631         log_error ""
632         log_error "A replication slot with this name already exists in the database."
633         log_error "This may indicate:"
634         log_error "  - A previous initialization that was not cleaned up"
635         log_error "  - Another pg_scribe instance using the same slot name"
636         log_error ""
637         log_error "To resolve:"
638         log_error "  - Use a different slot name with -S/--slot option"
639         log_error "  - Or drop the existing slot (if safe):"
640         log_error "    psql -d $DBNAME -c \"SELECT pg_drop_replication_slot('$SLOT');\""
641         exit "$EXIT_SLOT_ERROR"
642     fi
643
644     # Create slot using SQL
645     # Note: For POC, we create the slot and take the base backup sequentially
646     # The slot will preserve WAL from its creation LSN forward, ensuring no changes are lost
647     local slot_result
648     if ! slot_result=$(query_db "SELECT slot_name, lsn FROM pg_create_logical_replication_slot('$SLOT', 'wal2sql');"); then
649         log_error "Failed to create replication slot"
650         log_error "$slot_result"
651         exit "$EXIT_SLOT_ERROR"
652     fi
653
654     CREATED_SLOT="$SLOT"  # Track for cleanup
655     log_success "Replication slot '$SLOT' created"
656
657     # Take base backup immediately after slot creation
658     # The slot preserves WAL from its creation point, so all changes will be captured
659     local base_backup_file
660     base_backup_file="$FILE/base-$(date +%Y%m%d-%H%M%S).sql"
661     CREATED_FILES+=("$base_backup_file")  # Track for cleanup
662     log_info "Taking base backup: $base_backup_file"
663
664     local psql_args
665     mapfile -t psql_args < <(build_psql_args)
666     if pg_dump "${psql_args[@]}" --file="$base_backup_file"; then
667         log_success "Base backup completed: $base_backup_file"
668     else
669         log_error "Base backup failed"
670         exit "$EXIT_BACKUP_ERROR"
671     fi
672
673     # Take globals backup
674     log_info "Taking globals backup..."
675     local globals_backup_file
676     globals_backup_file="$FILE/globals-$(date +%Y%m%d-%H%M%S).sql"
677     CREATED_FILES+=("$globals_backup_file")  # Track for cleanup
678
679     # pg_dumpall doesn't use -d, only connection params
680     local dumpall_args=()
681     [[ -n "$HOST" ]] && dumpall_args+=(-h "$HOST")
682     [[ -n "$PORT" ]] && dumpall_args+=(-p "$PORT")
683     [[ -n "$USERNAME" ]] && dumpall_args+=(-U "$USERNAME")
684     [[ "$NO_PASSWORD" -eq 1 ]] && dumpall_args+=(-w)
685     [[ "$FORCE_PASSWORD" -eq 1 ]] && dumpall_args+=(-W)
686
687     if pg_dumpall "${dumpall_args[@]}" --globals-only --file="$globals_backup_file"; then
688         log_success "Globals backup completed: $globals_backup_file"
689     else
690         log_error "Globals backup failed"
691         exit "$EXIT_BACKUP_ERROR"
692     fi
693
694     # Generate metadata file
695     log_info "Generating metadata file..."
696     local metadata_file="$FILE/pg_scribe_metadata.txt"
697     CREATED_FILES+=("$metadata_file")  # Track for cleanup
698     local pg_version
699     pg_version=$(query_db "SELECT version();")
700
701     cat > "$metadata_file" <<EOF
702 pg_scribe Backup System Metadata
703 =================================
704
705 Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
706 pg_scribe Version: $VERSION
707
708 PostgreSQL Version:
709 $pg_version
710
711 Database: $DBNAME
712 Replication Slot: $SLOT
713
714 Extensions:
715 $(query_db "SELECT extname || ' ' || extversion FROM pg_extension ORDER BY extname;")
716
717 Encoding: $(query_db "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$DBNAME';")
718
719 Collation: $(query_db "SELECT datcollate FROM pg_database WHERE datname = '$DBNAME';")
720 EOF
721
722     log_success "Metadata file created: $metadata_file"
723
724     # Disable cleanup trap on successful completion
725     trap - EXIT INT TERM
726
727     # Final summary
728     echo >&2
729     log_step "Initialization Complete"
730     log_success "Backup directory: $FILE"
731     log_success "Replication slot: $SLOT"
732     log_info "Next steps:"
733     log_info "  1. Start streaming incremental backups:"
734     log_info "     pg_scribe --start -d $DBNAME -f $FILE/incremental.sql -S $SLOT"
735     log_info "  2. Monitor replication slot health:"
736     log_info "     pg_scribe --status -d $DBNAME -S $SLOT"
737
738     if [[ "$has_warnings" -eq 1 ]]; then
739         exit "$EXIT_WARNING"
740     else
741         exit "$EXIT_SUCCESS"
742     fi
743 }
744
745 #
746 # --start command implementation
747 #
748 cmd_start() {
749     log_step "Starting incremental backup collection"
750
751     # Validate required arguments
752     if [[ -z "$DBNAME" ]]; then
753         log_error "--start requires -d/--dbname"
754         exit "$EXIT_VALIDATION_ERROR"
755     fi
756
757     if [[ -z "$FILE" ]]; then
758         log_error "--start requires -f/--file (output file, or '-' for stdout)"
759         exit "$EXIT_VALIDATION_ERROR"
760     fi
761
762     # Test connection
763     test_connection
764
765     # Verify replication slot exists
766     log_step "Verifying replication slot '$SLOT'..."
767     local slot_exists
768     slot_exists=$(query_db "SELECT count(*) FROM pg_replication_slots WHERE slot_name = '$SLOT';")
769
770     if [[ "$slot_exists" -eq 0 ]]; then
771         log_error "Replication slot '$SLOT' does not exist"
772         log_error ""
773         log_error "You must initialize the backup system first:"
774         log_error "  pg_scribe --init -d $DBNAME -f <backup_dir> -S $SLOT"
775         log_error ""
776         log_error "Or verify the slot name is correct with:"
777         log_error "  psql -d $DBNAME -c \"SELECT slot_name FROM pg_replication_slots;\""
778         exit "$EXIT_SLOT_ERROR"
779     fi
780
781     log_success "Replication slot '$SLOT' found"
782
783     # Build pg_recvlogical arguments
784     local pg_recv_args=()
785     mapfile -t pg_recv_args < <(build_pg_recvlogical_args)
786
787     # Add required arguments
788     pg_recv_args+=(--slot="$SLOT")
789     pg_recv_args+=(--start)
790     pg_recv_args+=(--file="$FILE")
791
792     # Add plugin options
793     pg_recv_args+=(--option=include_transaction=on)
794
795     # Add status interval
796     pg_recv_args+=(--status-interval="$STATUS_INTERVAL")
797
798     # Add fsync interval (0 means disabled)
799     if [[ "$FSYNC_INTERVAL" -gt 0 ]]; then
800         pg_recv_args+=(--fsync-interval="$FSYNC_INTERVAL")
801     else
802         # For fsync-interval=0, we skip the parameter to avoid pg_recvlogical errors
803         log_info "Fsync disabled (fsync-interval=0)"
804     fi
805
806     # Display configuration
807     log_step "Configuration"
808     log_info "Database: $DBNAME"
809     log_info "Replication slot: $SLOT"
810     log_info "Output file: $FILE"
811     log_info "Status interval: ${STATUS_INTERVAL}s"
812     if [[ "$FSYNC_INTERVAL" -gt 0 ]]; then
813         log_info "Fsync interval: ${FSYNC_INTERVAL}s"
814     else
815         log_info "Fsync: disabled"
816     fi
817     echo >&2
818
819     # Start streaming with signal forwarding
820     log_step "Starting streaming replication..."
821     log_info "Press Ctrl+C to stop"
822     log_info "Send SIGHUP to rotate output file"
823     echo >&2
824
825     # Track child process for signal forwarding
826     local PG_RECVLOGICAL_PID=""
827
828     # Signal handler to forward signals to child process
829     # shellcheck disable=SC2317  # Function called via trap handler
830     forward_signal() {
831         local signal=$1
832         if [[ -n "$PG_RECVLOGICAL_PID" ]] && kill -0 "$PG_RECVLOGICAL_PID" 2>/dev/null; then
833             log_info "Forwarding SIG$signal to pg_recvlogical (PID $PG_RECVLOGICAL_PID)"
834             kill -"$signal" "$PG_RECVLOGICAL_PID" 2>/dev/null || true
835         fi
836     }
837
838     # Set up signal handlers
839     # shellcheck disable=SC2064  # We want the current value of the variable
840     trap "forward_signal HUP" HUP
841     trap "forward_signal TERM" TERM
842     trap "forward_signal INT" INT
843
844     # Execute pg_recvlogical in background so we can forward signals
845     pg_recvlogical "${pg_recv_args[@]}" &
846     PG_RECVLOGICAL_PID=$!
847
848     # Wait for child process
849     local exit_code=0
850     wait "$PG_RECVLOGICAL_PID" || exit_code=$?
851
852     # Clear signal handlers
853     trap - HUP TERM INT
854
855     # Check exit code
856     if [[ $exit_code -eq 0 ]]; then
857         log_success "Streaming stopped cleanly"
858         exit "$EXIT_SUCCESS"
859     elif [[ $exit_code -eq 130 ]]; then
860         # 130 = 128 + 2 (SIGINT), normal Ctrl+C
861         log_info "Interrupted by user"
862         exit "$EXIT_SUCCESS"
863     elif [[ $exit_code -eq 143 ]]; then
864         # 143 = 128 + 15 (SIGTERM), graceful shutdown
865         log_info "Terminated by signal"
866         exit "$EXIT_SUCCESS"
867     else
868         log_error "pg_recvlogical exited with code $exit_code"
869         exit "$EXIT_BACKUP_ERROR"
870     fi
871 }
872
873 #
874 # --full-backup command implementation
875 #
876 cmd_full_backup() {
877     log_error "--full-backup command not yet implemented"
878     exit "$EXIT_GENERAL_ERROR"
879 }
880
881 #
882 # --restore command implementation
883 #
884 cmd_restore() {
885     log_error "--restore command not yet implemented"
886     exit "$EXIT_GENERAL_ERROR"
887 }
888
889 #
890 # --status command implementation
891 #
892 cmd_status() {
893     log_error "--status command not yet implemented"
894     exit "$EXIT_GENERAL_ERROR"
895 }
896
897 # Main entry point
898 main() {
899     parse_args "$@"
900
901     case "$ACTION" in
902         init)
903             cmd_init
904             ;;
905         start)
906             cmd_start
907             ;;
908         full-backup)
909             cmd_full_backup
910             ;;
911         restore)
912             cmd_restore
913             ;;
914         status)
915             cmd_status
916             ;;
917         *)
918             log_error "Unknown action: $ACTION"
919             exit "$EXIT_GENERAL_ERROR"
920             ;;
921     esac
922 }
923
924 # Run main with all arguments
925 main "$@"