Repository navigation
Expand file tree
/
Copy pathods-backup.sh
More file actions
executable file
·773 lines (668 loc) · 26.1 KB
/
Copy pathods-backup.sh
File metadata and controls
executable file
·773 lines (668 loc) · 26.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
#!/bin/bash
# ods-backup.sh - ODS Backup Utility
# Part of M11: Update & Lifecycle Management
# Backs up user data and config before updates
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ODS_DIR="${ODS_DIR:-$SCRIPT_DIR}"
BACKUP_ROOT="${ODS_DIR}/.backups"
RETENTION_COUNT="${RETENTION_COUNT:-5}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Prerequisites check
command -v rsync >/dev/null 2>&1 || { echo -e "${RED}Error: rsync is required but not installed.${NC}" >&2; echo "Install with: apt install rsync (Debian/Ubuntu) or brew install rsync (macOS)" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo -e "${RED}Error: jq is required but not installed.${NC}" >&2; echo "Install with: apt install jq (Debian/Ubuntu) or brew install jq (macOS)" >&2; exit 1; }
# Logging functions
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
# Source shared rsync utilities
. "$ODS_DIR/lib/rsync.sh"
. "$ODS_DIR/lib/backup-paths.sh"
. "$SCRIPT_DIR/lib/dashboard-private-state.sh"
# Convert bytes to a human-friendly string (best-effort)
fmt_bytes() {
local bytes="${1:-0}"
if command -v numfmt >/dev/null 2>&1; then
numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B"
else
# Fallback: show MiB rounding
local mib=$(( (bytes + 1048575) / 1048576 ))
echo "${mib}MiB"
fi
}
# Available bytes on filesystem containing a path
free_bytes_for_path() {
local path="$1"
# df -P gives POSIX output; field 4 = available 1K-blocks
# Older mawk prints large integers in exponent notation with plain print.
df -Pk "$path" 2>/dev/null | awk 'NR==2 { printf "%.0f\n", $4 * 1024 }'
}
# Estimate bytes needed for a backup type (rough but safe)
estimate_backup_bytes() {
local backup_type="$1"
local total=0
# user data volumes
if [[ "$backup_type" == "full" || "$backup_type" == "user-data" ]]; then
local p
while IFS= read -r p; do
if [[ "$p" == data/pixel-images ]] && ods_private_images_selected; then
# Conservative bound includes SQLite pages/indexes. Do not
# estimate from a stale host shadow of the active volume.
total=$(( total + 512 * 1024 * 1024 ))
continue
fi
if [[ -e "$ODS_DIR/$p" ]]; then
local b
b=$(du -sk "$ODS_DIR/$p" 2>/dev/null | awk '{printf "%.0f\n", $1 * 1024}')
total=$(( total + ${b:-0} ))
fi
done < <(ods_backup_data_paths "$ODS_DIR")
fi
# config files/dir
if [[ "$backup_type" == "full" || "$backup_type" == "config" ]]; then
if [[ -d "$ODS_DIR/config" ]]; then
local b
b=$(du -sk "$ODS_DIR/config" 2>/dev/null | awk '{printf "%.0f\n", $1 * 1024}')
total=$(( total + ${b:-0} ))
fi
for f in "$ODS_DIR"/.env "$ODS_DIR"/.version "$ODS_DIR"/docker-compose*.y*ml "$ODS_DIR"/ods-preflight.sh "$ODS_DIR"/ods-update.sh; do
if [[ -f "$f" ]]; then
local s
s=$(wc -c < "$f" 2>/dev/null || echo 0)
total=$(( total + ${s:-0} ))
fi
done
fi
# cache (models + some caches)
if [[ "$backup_type" == "full" ]]; then
if [[ -d "$ODS_DIR/models" ]]; then
local b
b=$(du -sk "$ODS_DIR/models" 2>/dev/null | awk '{printf "%.0f\n", $1 * 1024}')
total=$(( total + ${b:-0} ))
fi
local -a cache_paths=("${ODS_BACKUP_CACHE_PATHS[@]}")
for p in "${cache_paths[@]}"; do
if [[ -d "$ODS_DIR/$p" ]]; then
local b
b=$(du -sk "$ODS_DIR/$p" 2>/dev/null | awk '{printf "%.0f\n", $1 * 1024}')
total=$(( total + ${b:-0} ))
fi
done
fi
# Add a small overhead buffer (manifest, filesystem slack, etc.)
total=$(( total + 50 * 1024 * 1024 ))
echo "$total"
}
ensure_backup_space() {
local backup_type="$1"
mkdir -p "$BACKUP_ROOT"
local need
need=$(estimate_backup_bytes "$backup_type")
local free
free=$(free_bytes_for_path "$BACKUP_ROOT")
if [[ -n "$free" && "$free" -lt "$need" ]]; then
log_error "Not enough disk space in $(dirname "$BACKUP_ROOT") to create backup."
log_error "Need ~$(fmt_bytes "$need"), have ~$(fmt_bytes "$free")."
log_error "Free up space or use --output to write backups to another disk."
exit 1
fi
}
# Collect backup entries (dirs or .tar.gz archives) under BACKUP_ROOT into
# the global COLLECTED_BACKUPS array, newest first.
#
# Backup IDs are YYYYMMDD-HHMMSS (this script) or <prefix>-YYYYMMDD-HHMMSS
# (dashboard/host-agent). Match that shape explicitly: retention deletes
# whatever this collects, so an operator's unrelated files in the backup
# directory must never qualify.
COLLECTED_BACKUPS=()
collect_backups() {
COLLECTED_BACKUPS=()
local entry base
while IFS= read -r -d '' entry; do
COLLECTED_BACKUPS+=("${entry#*$'\t'}")
done < <(
while IFS= read -r -d '' entry; do
base=$(basename "$entry")
# Keep the existing ID shapes, including multi-segment labels.
[[ "$base" =~ ^([A-Za-z0-9_][A-Za-z0-9_-]*-)?([0-9]{8}-[0-9]{6})(\.tar\.gz)?$ ]] || continue
# Sort by the embedded creation timestamp before the optional
# label. NUL records preserve whitespace in the backup root.
printf '%s\t%s\0' "${BASH_REMATCH[2]}" "$entry"
done < <(find "$BACKUP_ROOT" -maxdepth 1 \( -type d -o -name "*.tar.gz" \) -print0 2>/dev/null) \
| LC_ALL=C sort -z -r
)
}
# Show usage
usage() {
cat << EOF
ODS Backup Utility
Usage: $(basename "$0") <command> [OPTIONS]
Commands:
backup Create a backup (default)
verify <backup_id> Verify checksums for an existing backup
OPTIONS:
-h, --help Show this help message
-o, --output DIR Custom backup directory (default: .backups/)
-t, --type TYPE Backup type: full, user-data, config (default: user-data)
-c, --compress Compress backup to .tar.gz
-l, --list List existing backups
-d, --delete ID Delete specific backup by ID
--description DESC Add description to backup manifest
BACKUP TYPES:
full Backup everything (user data + config + cache)
user-data Backup only user data volumes (default)
config Backup only configuration files; native Pixel state is excluded
Native Pixel full/user-data backup is unsupported and fails before creating an archive.
EXAMPLES:
$(basename "$0") # Backup (default: user-data)
$(basename "$0") -t full -c # Compressed full backup
$(basename "$0") -l # List all backups
$(basename "$0") -d 20260212-071500 # Delete specific backup
$(basename "$0") verify 20260212-071500 # Verify checksum integrity
$(basename "$0") verify 20260212-071500.tar.gz
$(basename "$0") backup -t config # Explicit backup subcommand
EOF
}
# List existing backups
list_backups() {
if [[ ! -d "$BACKUP_ROOT" ]]; then
log_info "No backups found (backup directory doesn't exist)"
return 0
fi
collect_backups
if [[ ${#COLLECTED_BACKUPS[@]} -eq 0 ]]; then
log_info "No backups found"
return 0
fi
echo ""
echo "Existing Backups:"
echo "═══════════════════════════════════════════════════════════════════"
printf "%-20s %-12s %-10s %s\n" "ID" "Type" "Size" "Description"
echo "───────────────────────────────────────────────────────────────────"
for backup in "${COLLECTED_BACKUPS[@]}"; do
local id
id=$(basename "$backup")
local backup_type="unknown"
local description=""
local size
size=$(du -sh "$backup" 2>/dev/null | cut -f1)
if [[ "$backup" == *.tar.gz ]]; then
# Compressed archive — extract manifest from inside the tar
local manifest_data
local archive_name="${id%.tar.gz}"
if manifest_data=$(tar xzf "$backup" -O "${archive_name}/manifest.json" 2>/dev/null); then
backup_type=$(echo "$manifest_data" | grep -o '"backup_type": "[^"]*"' 2>/dev/null | cut -d'"' -f4 || echo "compressed")
description=$(echo "$manifest_data" | grep -o '"description": "[^"]*"' 2>/dev/null | cut -d'"' -f4 || echo "")
else
backup_type="compressed"
fi
elif [[ -f "$backup/manifest.json" ]]; then
backup_type=$(grep -o '"backup_type": "[^"]*"' "$backup/manifest.json" 2>/dev/null | cut -d'"' -f4 || echo "unknown")
description=$(grep -o '"description": "[^"]*"' "$backup/manifest.json" 2>/dev/null | cut -d'"' -f4 || echo "")
fi
printf "%-20s %-12s %-10s %s\n" "$id" "$backup_type" "$size" "$description"
done
echo ""
}
# Delete specific backup
delete_backup() {
local backup_id="$1"
# Reject path traversal attempts
if [[ "$backup_id" == *..* || "$backup_id" == */* || "$backup_id" == *\\* ]]; then
log_error "Invalid backup ID: $backup_id"
return 1
fi
local target="$BACKUP_ROOT/$backup_id"
# Compressed backups are .tar.gz files, not directories; also accept the
# bare ID for an archive-only backup.
if [[ ! -e "$target" && -f "$target.tar.gz" ]]; then
target="$target.tar.gz"
fi
if [[ ! -e "$target" ]]; then
log_error "Backup not found: $backup_id"
return 1
fi
read -rp "Are you sure you want to delete backup $(basename "$target")? [y/N] " confirm || confirm=""
if [[ "$confirm" =~ ^[Yy]$ ]]; then
if ! rm -rf -- "$target"; then
log_error "Failed to delete backup: $(basename "$target")"
return 1
fi
log_success "Deleted backup: $(basename "$target")"
else
log_info "Deletion cancelled"
fi
}
# Create backup manifest
create_manifest() {
local backup_dir="$1"
local backup_type="$2"
local description="${3:-}"
local version
# .version is a JSON file written by ods-update.sh; extract the version
# string rather than embedding the entire JSON blob (see get_current_version
# in ods-update.sh for the canonical reader).
version=$(jq -r '.version // "unknown"' "$ODS_DIR/.version" 2>/dev/null || echo "unknown")
# Use jq to safely construct JSON (prevents injection via $description)
local has_user_data="false" has_config="false" has_cache="false"
[[ "$backup_type" == "full" || "$backup_type" == "user-data" ]] && has_user_data="true"
[[ "$backup_type" == "full" || "$backup_type" == "config" ]] && has_config="true"
[[ "$backup_type" == "full" ]] && has_cache="true"
local user_data_paths_json
user_data_paths_json=$(
ods_backup_data_paths "$ODS_DIR" \
| jq -R -s 'split("\n") | map(select(length > 0))'
)
jq -n \
--arg mv "1.0" \
--arg bd "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--arg bi "$(basename "$backup_dir")" \
--arg bt "$backup_type" \
--arg dv "$version" \
--arg hn "$(hostname)" \
--arg desc "$description" \
--argjson ud "$has_user_data" \
--argjson cfg "$has_config" \
--argjson ca "$has_cache" \
--argjson udp "$user_data_paths_json" \
--argjson npe "${4:-false}" \
'{
manifest_version: $mv,
backup_date: $bd,
backup_id: $bi,
backup_type: $bt,
ods_version: $dv,
hostname: $hn,
description: $desc,
contents: { user_data: $ud, config: $cfg, cache: $ca },
paths: (
{
env: ".env",
compose: "docker-compose.yml",
config: "config"
}
+ ($udp
| map({key: (gsub("[^A-Za-z0-9_]"; "_")), value: .})
| from_entries)
)
} + (if $npe then {native_pixel: {included: false, reason: "unsupported-native-state"}} else {} end)' > "$backup_dir/manifest.json"
log_info "Created backup manifest"
}
# Backup user data volumes
backup_user_data() {
local backup_dir="$1"
log_info "Backing up user data volumes..."
# Entries may be directories or single files; rsync -a keeps file modes,
# so a 0600 secret such as dashboard-password.json stays 0600.
local path
while IFS= read -r path; do
if [[ "$path" == data/pixel-images ]] && ods_private_images_selected; then
ods_private_images export --path "$backup_dir/$path" || return 1
log_success "Backed up: $path (private Docker volume)"
continue
fi
local full_path="$ODS_DIR/$path"
if [[ -e "$full_path" && ! -L "$full_path" ]]; then
local dest_dir="$backup_dir/$(dirname "$path")"
mkdir -p "$dest_dir"
rsync_with_progress "$full_path" "$dest_dir/" "Backing up $path"
log_success "Backed up: $path"
else
log_warn "Skipped (not found): $path"
fi
done < <(ods_backup_data_paths "$ODS_DIR")
}
# Backup configuration
backup_config() {
local backup_dir="$1"
log_info "Backing up configuration..."
# Essential config files: discover compose overlays + dotfiles dynamically
for file in "$ODS_DIR"/.env "$ODS_DIR"/.version "$ODS_DIR"/docker-compose*.y*ml "$ODS_DIR"/ods-preflight.sh "$ODS_DIR"/ods-update.sh; do
if [[ -f "$file" ]]; then
cp "$file" "$backup_dir/"
log_success "Backed up: $(basename "$file")"
fi
done
# Config directory
if [[ -d "$ODS_DIR/config" ]]; then
rsync_with_progress "$ODS_DIR/config" "$backup_dir/" "Backing up config/"
log_success "Backed up: config/"
fi
}
# Generate a SHA256 manifest for all backed up files
# Writes: $backup_dir/checksums.sha256 (format: sha256 relative/path)
create_checksums() {
local backup_dir="$1"
log_info "Generating checksums..."
local checksum_file="$backup_dir/checksums.sha256"
if command -v sha256sum >/dev/null 2>&1; then
(
cd "$backup_dir"
# Deterministic ordering; exclude the checksum file itself.
find . -type f ! -name "checksums.sha256" -print0 \
| sort -z \
| xargs -0 sha256sum \
| sed 's# \./# #' \
> "$(basename "$checksum_file")"
)
elif command -v shasum >/dev/null 2>&1; then
(
cd "$backup_dir"
find . -type f ! -name "checksums.sha256" -print0 \
| sort -z \
| xargs -0 shasum -a 256 \
| sed 's# \./# #' \
> "$(basename "$checksum_file")"
)
else
log_warn "Skipping checksums: sha256sum/shasum not found"
return 0
fi
log_info "Wrote checksums.sha256"
}
# Backup cache (optional, for full backups)
backup_cache() {
local backup_dir="$1"
log_info "Backing up cache (models, etc.)..."
if [[ -d "$ODS_DIR/models" ]]; then
rsync_with_progress "$ODS_DIR/models" "$backup_dir/" "Backing up models/"
log_success "Backed up: models/"
fi
# Bind-mounted cache directories (lib/backup-paths.sh): the GGUF weights
# and the STT/embeddings model caches the user-data type deliberately skips.
local cache_paths=("${ODS_BACKUP_CACHE_PATHS[@]}")
for path in "${cache_paths[@]}"; do
if [[ -d "$ODS_DIR/$path" ]]; then
local dest_dir="$backup_dir/$(dirname "$path")"
mkdir -p "$dest_dir"
rsync_with_progress "$ODS_DIR/$path" "$dest_dir/" "Backing up $path"
log_success "Backed up: $path"
fi
done
}
# Apply retention policy - keep only N most recent backups
apply_retention() {
log_info "Applying retention policy (keeping last $RETENTION_COUNT backups)..."
collect_backups
local count=${#COLLECTED_BACKUPS[@]}
if [[ $count -gt $RETENTION_COUNT ]]; then
local to_delete=$((count - RETENTION_COUNT))
log_info "Removing $to_delete old backup(s)..."
for ((i=RETENTION_COUNT; i<count; i++)); do
local old_backup="${COLLECTED_BACKUPS[$i]}"
log_warn "Removing old backup: $(basename "$old_backup")"
rm -rf "$old_backup"
done
else
log_info "Retention policy satisfied ($count/$RETENTION_COUNT backups)"
fi
}
# Compress backup
compress_backup() {
local backup_dir="$1"
log_info "Compressing backup..."
local backup_name
backup_name=$(basename "$backup_dir")
local parent_dir
parent_dir=$(dirname "$backup_dir")
local archive="${2:-$parent_dir/$backup_name.tar.gz}"
# macOS `tar` (bsdtar) embeds AppleDouble `._*` metadata companions when the
# staged files carry extended attributes (e.g. com.apple.provenance, which the
# OS sets on ordinary files). The top-level `._<backup_id>` entry then fails
# backup-archive.py's member check on restore — its first path component is
# not the backup id — so the whole archive is rejected and macOS users cannot
# recover their own backups. COPYFILE_DISABLE tells bsdtar to omit that
# metadata; GNU tar on Linux ignores the variable, so this is a no-op there.
COPYFILE_DISABLE=1 tar czf "$archive" -C "$parent_dir" "$backup_name"
# The archive bundles the raw .env (DASHBOARD_API_KEY, session secret, service
# passwords). Restrict it to the owner rather than leaving it world-readable
# at the umask default, matching the 0600 the .env itself carries.
chmod 600 "$archive"
local compressed_size
compressed_size=$(du -sh "$archive" | cut -f1)
# Remove uncompressed version
rm -rf "$backup_dir"
log_success "Compressed backup: $backup_name.tar.gz ($compressed_size)"
}
# Main backup function
do_backup() (
local backup_type="${1:-user-data}"
local compress="${2:-false}"
local description="${3:-}"
local native_scope native_excluded=false
if ! native_scope=$(python3 "$SCRIPT_DIR/scripts/backup-native-preflight.py" backup \
--install-dir "$ODS_DIR" --backup-type "$backup_type"); then
return 1
fi
[[ "$native_scope" == native-excluded ]] && native_excluded=true
case "$backup_type" in
full|user-data|config) ;;
*) log_error "Unknown backup type: $backup_type"; return 1 ;;
esac
# Generate backup ID
local backup_id
# Include the process ID so concurrent invocations cannot share one
# second-granularity directory. A merged directory would make either
# snapshot incomplete and could make a later restore select mixed data.
backup_id="backup-$$-$(date +%Y%m%d-%H%M%S)"
local backup_dir="$BACKUP_ROOT/$backup_id"
log_info "Starting $backup_type backup: $backup_id"
log_info "Backup directory: $backup_dir"
# Disk space preflight (best-effort)
ensure_backup_space "$backup_type"
# Keep copies, manifests and compression outside the discoverable backup
# namespace until all steps succeed. The private parent also protects raw
# configuration secrets while the snapshot and archive are being written.
local staging_root staged_backup
staging_root=$(mktemp -d "$BACKUP_ROOT/.partial-$backup_id.XXXXXX")
trap 'rm -rf -- "$staging_root"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
staged_backup="$staging_root/$backup_id"
mkdir "$staged_backup"
# Create manifest
create_manifest "$staged_backup" "$backup_type" "$description" "$native_excluded"
# Perform backup based on type
case "$backup_type" in
full)
backup_user_data "$staged_backup"
backup_config "$staged_backup"
backup_cache "$staged_backup"
;;
user-data)
backup_user_data "$staged_backup"
;;
config)
backup_config "$staged_backup"
;;
esac
# Generate checksums after files are copied into place
create_checksums "$staged_backup"
if [[ ! -f "$staged_backup/checksums.sha256" ]]; then
log_error "Cannot publish backup without checksums.sha256"
return 1
fi
# Compress if requested
if [[ "$compress" == "true" ]]; then
compress_backup "$staged_backup" "$staging_root/$backup_id.tar.gz"
staged_backup="$staging_root/$backup_id.tar.gz"
backup_dir="$BACKUP_ROOT/$backup_id.tar.gz"
fi
# Refuse to merge into or replace an existing recovery artifact. Staging is
# on the same filesystem, so the completed snapshot becomes visible at once.
if [[ -e "$backup_dir" || -L "$backup_dir" ]]; then
log_error "Backup destination already exists: $backup_dir"
return 1
fi
mv "$staged_backup" "$backup_dir"
# Apply retention policy
apply_retention
log_success "Backup complete: $backup_id"
echo ""
if [[ "$native_excluded" == true ]]; then
echo "Configuration-only archive: native Pixel state is excluded."
echo "This archive is for inspection; automatic restore is unsupported. Retain the original state."
else
echo "To restore this backup, run:"
echo " ods-restore.sh $backup_id"
fi
)
# Verify checksums for an existing backup directory or archive
verify_backup() {
local backup_id="$1"
# Reject path traversal attempts
if [[ "$backup_id" == *..* || "$backup_id" == */* || "$backup_id" == *\\* ]]; then
log_error "Invalid backup ID: $backup_id"
return 1
fi
local target="$BACKUP_ROOT/$backup_id"
if [[ -d "$target" ]]; then
if [[ ! -f "$target/checksums.sha256" ]]; then
log_error "checksums.sha256 not found for backup: $backup_id"
return 1
fi
(
cd "$target"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "checksums.sha256"
else
shasum -a 256 -c "checksums.sha256"
fi
)
log_success "Backup verified: $backup_id"
return 0
fi
if [[ -f "$target" && "$target" == *.tar.gz ]]; then
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' RETURN
# Extract checksums first (fast fail if missing)
local archive_name="${backup_id%.tar.gz}"
if ! tar xzf "$target" -C "$tmpdir" "${archive_name}/checksums.sha256" >/dev/null 2>&1; then
log_error "checksums.sha256 not found in archive: $backup_id"
return 1
fi
tar xzf "$target" -C "$tmpdir"
(
cd "$tmpdir/$archive_name"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "checksums.sha256"
else
shasum -a 256 -c "checksums.sha256"
fi
)
log_success "Backup verified: $backup_id"
return 0
fi
log_error "Backup not found: $backup_id"
return 1
}
# Main entry point
main() {
local subcommand="backup"
# Allow subcommands to appear either first (e.g. `verify ...`) or after options
# (e.g. `--output /path verify ...`). We'll detect `backup`/`verify` during arg parsing.
local backup_type="user-data"
local compress="false"
local description=""
local list_mode="false"
local delete_id=""
local verify_id=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
backup)
subcommand="backup"
shift
;;
verify)
subcommand="verify"
shift
verify_id="${1:-}"
if [[ -z "$verify_id" ]]; then
log_error "Usage: $(basename "$0") verify <backup_id|backup_id.tar.gz>"
exit 1
fi
shift
# No more flags after verify are supported
break
;;
-h|--help)
usage
exit 0
;;
-o|--output)
BACKUP_ROOT="$2"
shift 2
;;
-t|--type)
backup_type="$2"
shift 2
;;
-c|--compress)
compress="true"
shift
;;
-l|--list)
list_mode="true"
shift
;;
-d|--delete)
delete_id="$2"
shift 2
;;
--description)
description="$2"
shift 2
;;
*)
log_error "Unknown option: $1"
usage
exit 1
;;
esac
done
# List mode
if [[ "$list_mode" == "true" ]]; then
list_backups
exit 0
fi
# Delete mode
if [[ -n "$delete_id" ]]; then
delete_backup "$delete_id"
exit $?
fi
# Verify mode
if [[ "$subcommand" == "verify" ]]; then
# Create backup root so --output works even if dir doesn't exist yet
mkdir -p "$BACKUP_ROOT"
verify_backup "$verify_id"
exit $?
fi
# Check if running in ODS directory
local has_compose=false
for f in "$ODS_DIR"/docker-compose*.y*ml; do
[[ -f "$f" ]] && has_compose=true && break
done
if [[ "$has_compose" == "false" && ! -d "$ODS_DIR/data" ]]; then
log_warn "This doesn't appear to be a ODS directory"
log_warn "Expected: docker-compose.yml or data/ directory"
read -rp "Continue anyway? [y/N] " confirm || confirm=""
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# do_backup checks native-state support before creating any backup files.
# Perform backup
do_backup "$backup_type" "$compress" "$description"
}
if [[ "${ODS_BACKUP_SOURCE_ONLY:-false}" != "true" ]]; then
main "$@"
fi