-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathvmrestore.sh
More file actions
executable file
·3808 lines (3516 loc) · 192 KB
/
Copy pathvmrestore.sh
File metadata and controls
executable file
·3808 lines (3516 loc) · 192 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
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
#################################################################################
# vmrestore — Automated Restore for libvirt/KVM Virtual Machines
# Vibe coded by James Doutsis — james@doutsis.com
#
# Wraps virtnbdrestore to provide single-command disaster recovery, clone
# restores and point-in-time recovery — with full identity management,
# TPM/BitLocker support and pre-flight safety checks.
#
# Features:
# - Disaster recovery: rebuilds VM with original UUID, MACs and name
# - Clone mode: independent copy with new identity (one flag: --name)
# - Disk restore: in-place single-disk replacement or extract to staging
# - Point-in-time: restore to any restore point, period or archived chain
# - TPM/BitLocker: state restored automatically in both DR and clone mode
# - UEFI/NVRAM isolation: clone gets its own firmware state file
# - Pre-flight safety: disk collision, free space, running VM detection
# - Auto-detection: backup type, period, chain layout, storage pool
# - Dry-run mode: preview every action without writing anything
#
# Prerequisites:
# virtnbdbackup >= 2.28 virtnbdrestore (disk restore engine)
# libvirt-daemon-system virsh domain management
# qemu-utils qemu-img for post-restore disk checks
# bash >= 5.0 required for associative arrays
#
# Usage:
# vmrestore --help
#
# Repository:
# https://github.com/doutsis/vmrestore
#
# Relationship to vmbackup:
# vmrestore is a standalone script — no shared code, no sourced modules,
# no runtime coupling to vmbackup.sh. But it exclusively restores backups
# created by vmbackup. The two scripts are complementary halves of one
# system: vmbackup backs up, vmrestore restores.
#
#################################################################################
#
# SCRIPT ARCHITECTURE
# ===================
# Single self-contained script. No modules, no database,
# no runtime dependency on vmbackup.
#
# Section layout (search with "# ── Section Name"):
#
# Logging Log file init, structured log_info/warn/error
# Configuration Backup path resolution from vmbackup config
# Pre-flight Free Space Destination capacity check before restore
# Backup Detection Full vs incremental, checkpoint enumeration
# Path Resolution Period/chain/archive path discovery
# Listing --list and --list-restore-points output
# Storage Pool Refresh Longest-prefix pool match + virsh pool-refresh
# TPM Restore swtpm state dir recreation at correct UUID
# New-Identity Define Clone mode: strip UUID/MACs, rename, define
# Disk Enumeration enumerate_disks() for --disk validation/display
# Disk Collision Protection Predict output files, check for conflicts
# Core Restore Main restore_vm() orchestration function
# Verify / Dump --verify checksum validation, --dump output
# Usage --help output
# CLI Parsing Argument parsing and validation
# Main Entry point, mode dispatch
#
# Restore flow (inside restore_vm):
#
# 1. Resolve backup path, detect layout, find latest period/chain
# 2. Pre-flight: disk collision, free space, running VM checks
# 3. Disk-restore mode: if --disk set, branch to in-place replacement
# or staging extract (no VM define, no TPM)
# 4. Run virtnbdrestore (DR: -c -D, clone: -c with staging dir)
# 5. DR: re-inject original UUID and MACs
# Clone: strip UUID/MACs, rename disks, define with new identity
# 6. Restore TPM state, isolate NVRAM for clones
# 7. qemu-img check, storage pool refresh, completion summary
#
#################################################################################
#
# DISCLAIMER
# ==========
# 100% vibe coded. Could be 100% wrong.
# Appropriate testing in any and all environments is required.
# Build your own confidence that the backups work.
# Backups are only as good as your restores.
#
#################################################################################
set -uo pipefail
# UNI-003 + UNI-008 + UNI-321: Source bootstrap.sh (lib loader) + version
# + exit codes from lib/. This is the ONLY remaining inline source block —
# bootstrap.sh provides source_lib_or_die for every subsequent lib load.
# Done very early because the rest of the script depends on these constants.
# Variable name is _VMBACKUP_LIB_DIR (package-scoped) so both binaries
# share the same canonical name.
_VMBACKUP_LIB_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/lib" && pwd 2>/dev/null)" || _VMBACKUP_LIB_DIR=""
for _lib in bootstrap.sh version.sh exit_codes.sh; do
if [[ -r "$_VMBACKUP_LIB_DIR/$_lib" ]]; then
# shellcheck source=/dev/null
source "$_VMBACKUP_LIB_DIR/$_lib"
elif [[ -r "/opt/vmbackup/lib/$_lib" ]]; then
# shellcheck source=/dev/null
source "/opt/vmbackup/lib/$_lib"
else
echo "Error: lib/$_lib not found" >&2; exit 8
fi
done
unset _VMBACKUP_LIB_DIR _lib
# ── Logging ──────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# UNI-001: Source unified logging library. Provides log_msg/info/warn/
# error/debug with 3-arg signature (process, fn, msg). Replaces previous
# inline 2-arg log_* definitions; vmrestore now calls log_X with 3 args.
# LOG_FILE is checked at call time (not source time); fine to source early.
source_lib_or_die logging.sh
# UNI-323 (Phase 7 commit 2): Source canonical path-canonicalisation API
# (pu_normalise_path, pu_safe_realpath, pu_strip_trailing_slash,
# pu_ensure_trailing_slash, pu_join_paths). Strict-alphabetical position:
# logging < path_utils < period. No load-order dependency (pure functions).
source_lib_or_die path_utils.sh
# UNI-011: Source VM-name sanitiser. Reject-not-substitute contract; relies
# on EXIT_USAGE being defined (it is, just above).
source_lib_or_die vm_name_utils.sh
# UNI-007: Source period-helper library. Provides get_vm_periods,
# get_vm_periods_for_policy, detect_period_policy, calculate_any_period_age.
# vmrestore consumes get_vm_periods (in list_periods); the other three are
# unused here but loaded for symmetry with vmbackup. See Phase 3 spec §4
# commit 2.
source_lib_or_die period.sh
# UNI-309: Source backup-tree walker (skeletal, D2). Provides walk_backup_tree()
# used by list_vms() for VM-level skip-list convergence (B). Period-level skip
# convergence in vmrestore is handled by lib/period.sh (get_vm_periods'
# strict period-shape regex), which list_periods() delegates to.
source_lib_or_die backup_walker.sh
# UNI-006 (Phase 6 commit 2): Source shared TPM read-side helpers
# (get_vm_uuid, has_tpm_device, validate_tpm_backup, …) so restore_tpm()
# in this binary does not depend on the optional, lazy-loaded
# modules/tpm_backup_module.sh.
source_lib_or_die tpm_io.sh
# UNI-014 M1 (Phase 6 commit 3): Source virtnbd arg-builder helpers.
# Used by the per-disk loop, full-restore array, and run_virtnbd_action.
# Retry path (post-failure -c -U qemu:///system) stays inline (out of M1).
source_lib_or_die virtnbd.sh
# UNI-013 (Phase 6 commit 4): Source libvirt domain/XML/pool helpers.
# Replaces 26 inline virsh callsites with lv_* wrappers (see
# 109-phase6-spec.md §2.4 and tests/baselines/phase6/libvirt-fn-table.md).
source_lib_or_die libvirt.sh
# UNI-012: Source config-instance path resolver. Provides
# resolve_config_instance() and get_config_file(); used by resolve_backup_path().
source_lib_or_die config_instance.sh
# UNI-322 (Phase 7 commit 1): Source canonical dry-run predicate API
# (is_dry_run, if_dry_run, if_not_dry_run + dry_run_normalise_state).
# Strict-alphabetical position: config_instance < dry_run < exit_codes.
# CLI parser (--dry-run handler) re-invokes dry_run_normalise_state
# after parsing completes — see end of CLI parsing section.
source_lib_or_die dry_run.sh
# UNI-002: Source per-VM locking library. Same lock-file naming convention
# as vmbackup ("vmbackup-<vm>.lock") so the two tools mutually exclude.
# Requires \$LOCK_DIR set before any create_lock/remove_lock call (set in
# main() once \$OPT_BACKUP_PATH is known; see UNI-002 wrapper around
# restore_vm()).
source_lib_or_die vm_lock.sh
# UNI-010: Source shared signal-trap registration helpers.
source_lib_or_die signal_handlers.sh
# Phase 4 (UNI-605 / USE-03): Source the SQLite read-only library so --list
# can enrich each VM's output with chain-health summary line. Optional —
# vmrestore must remain usable on hosts that do not have a vmbackup ledger
# DB present (and on hosts without the sqlite3 binary). The library is
# sourced eagerly here for symbol availability; sqlite_init_readonly() is
# only called on demand from list_vms (memoised via
# _VMRESTORE_SQLITE_RO_INITED). All call sites guard on
# SQLITE_MODULE_AVAILABLE before issuing queries, so missing DB / missing
# sqlite3 / permission error all degrade silently to the pre-Phase-4
# walker-only output.
# UNI-321: kept optional (try/no-fail) because vmrestore must work on
# hosts without sqlite_ro.sh installed.
if [[ -r "$SCRIPT_DIR/lib/sqlite_ro.sh" ]]; then
# shellcheck source=lib/sqlite_ro.sh
source "$SCRIPT_DIR/lib/sqlite_ro.sh"
elif [[ -r /opt/vmbackup/lib/sqlite_ro.sh ]]; then
source /opt/vmbackup/lib/sqlite_ro.sh
fi
# Note: missing sqlite_ro.sh is NOT fatal — vmrestore stays standalone-capable.
# Process-scoped memo: sqlite_init_readonly is called at most once per run.
_VMRESTORE_SQLITE_RO_INITED=0
_VMRESTORE_SQLITE_RO_OK=0
# Phase 8 (UNI-902b): Source the SQLite write-side library so vmrestore can
# log restore_sessions rows. Same warn-and-continue contract as sqlite_ro.sh
# (missing module is NOT fatal — DR invariant: vmrestore never blocks on the
# catalogue being unreachable). All call sites guard on sqlite_is_available
# before invoking writers.
if [[ -r "$SCRIPT_DIR/lib/sqlite_module.sh" ]]; then
# shellcheck source=lib/sqlite_module.sh
source "$SCRIPT_DIR/lib/sqlite_module.sh"
elif [[ -r /opt/vmbackup/lib/sqlite_module.sh ]]; then
# shellcheck source=/dev/null # installed-tree fallback, may not be readable at lint time
source /opt/vmbackup/lib/sqlite_module.sh
fi
# _RESTORE_SESSION_ID: rowid of the in-flight restore_sessions row, set by
# sqlite_restore_session_start() and consumed by _vmrestore_cleanup().
# Empty → start was skipped (DB unavailable / dry-run / read-only mode) or
# never reached (init failure) → end-call is a no-op.
_RESTORE_SESSION_ID=""
# UNI-010: Cleanup state. _VMRESTORE_LOCK_VM is set after create_lock; the
# EXIT trap releases the lock if still held. _VMRESTORE_STAGING_DIRS tracks
# any PIT staging dirs and clone-mode staging dirs created so a SIGINT/SIGTERM
# mid-restore removes them (success paths still call cleanup_pit_staging /
# rm -rf the clone staging directly; the trap is the safety net for
# interrupted runs).
_VMRESTORE_LOCK_VM=""
declare -a _VMRESTORE_STAGING_DIRS=()
# F-vmr2514: a vmconfig.virtnbdbackup.0.xml provisioned INTO the backup/archive
# dir for archived chains (restore_vm) must not survive a die/signal before the
# success-path rm — the backup tree is read-only. Trap-tracked so any exit
# restores it. Empty unless restore_vm provisioned one this run.
_VMRESTORE_PROVISIONED_VMCONFIG=""
_vmrestore_cleanup() {
local rc=$?
# Remove tracked staging dirs (idempotent; cleanup_pit_staging is rm -rf)
if (( ${#_VMRESTORE_STAGING_DIRS[@]} > 0 )); then
local d
for d in "${_VMRESTORE_STAGING_DIRS[@]}"; do
[[ -n "$d" && -d "$d" ]] || continue
log_warn "vmrestore.sh" "_vmrestore_cleanup" "Removing staging dir on exit (rc=$rc): $d"
rm -rf "$d" 2>/dev/null || true
done
_VMRESTORE_STAGING_DIRS=()
fi
# F-vmr2514: remove a provisioned vmconfig left in the backup/archive dir by
# an interrupted/failed restore_vm (the backup tree is read-only). The happy
# path already rm's it at the end of the else-block and clears this global,
# so on success this is a no-op. The -f guard + the "this-run-only" creation
# invariant (provisioning runs only when no vmconfig.virtnbdbackup.*.xml
# pre-existed) ensure we never delete a genuine backup artefact.
if [[ -n "${_VMRESTORE_PROVISIONED_VMCONFIG:-}" && -f "$_VMRESTORE_PROVISIONED_VMCONFIG" ]]; then
log_warn "vmrestore.sh" "_vmrestore_cleanup" "Removing provisioned vmconfig on exit (rc=$rc): $_VMRESTORE_PROVISIONED_VMCONFIG"
rm -f "$_VMRESTORE_PROVISIONED_VMCONFIG" 2>/dev/null || true
_VMRESTORE_PROVISIONED_VMCONFIG=""
fi
# Release lock if still held
if [[ -n "$_VMRESTORE_LOCK_VM" && -n "${LOCK_DIR:-}" ]]; then
log_warn "vmrestore.sh" "_vmrestore_cleanup" "Releasing lock on exit (rc=$rc): VM '$_VMRESTORE_LOCK_VM'"
remove_lock "$_VMRESTORE_LOCK_VM" 2>/dev/null || true
_VMRESTORE_LOCK_VM=""
fi
# Phase 8 (UNI-902b): finalise restore_sessions row if one is in flight.
# Trap fires unconditionally (success / die / SIGINT / SIGTERM); this is
# the only place that sees the final rc. sqlite_restore_session_end is
# idempotent via _SQLITE_RESTORE_SESSION_ENDED so a re-entrant trap is
# harmless. Empty session-id → start was skipped → end is a no-op.
if [[ -n "${_RESTORE_SESSION_ID:-}" ]] && declare -F sqlite_is_available >/dev/null 2>&1 && sqlite_is_available; then
local final_status
case "$rc" in
0) final_status=success ;;
130|143) final_status=interrupted ;; # SIGINT / SIGTERM
*) final_status=failed ;;
esac
local _rse_rc=0
sqlite_restore_session_end "$_RESTORE_SESSION_ID" "$final_status" "$rc" || _rse_rc=$?
# rc=2 = idempotent no-op (writer was already called); rc=1 = real failure.
# Only warn on the latter — see INT-13.
if (( _rse_rc != 0 && _rse_rc != 2 )); then
log_warn "vmrestore.sh" "_vmrestore_cleanup" "restore session end-row not recorded"
fi
fi
return $rc
}
_vmrestore_handle_sigint() {
log_error "vmrestore.sh" "main" "Restore interrupted by SIGINT (Ctrl+C)"
log_error "vmrestore.sh" "main" "Recovery: re-run vmrestore; lock and staging dir have been removed"
# _vmrestore_cleanup will fire via EXIT trap with rc=130
exit 130
}
_vmrestore_handle_sigterm() {
log_error "vmrestore.sh" "main" "Restore terminated by SIGTERM (timeout or systemd stop)"
exit 143
}
if [[ -d /var/log/vmrestore ]]; then
LOG_DIR="${LOG_DIR:-/var/log/vmrestore}"
else
LOG_DIR="${LOG_DIR:-$SCRIPT_DIR/logs}"
fi
LOG_FILE=""
START_EPOCH=""
ORIG_ARGS=""
init_logging() {
START_EPOCH=$(date +%s)
mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR="/tmp"
# Temporary log until we know the VM name (finalize_log renames it)
LOG_FILE="$LOG_DIR/vmrestore-$(date +%Y%m%d-%H%M%S).log"
touch "$LOG_FILE" 2>/dev/null || LOG_FILE="/tmp/vmrestore-$(date +%s).log"
chmod 600 "$LOG_FILE" 2>/dev/null || true
}
finalize_log_name() {
# Rename log to include VM name: {vmname}-{timestamp}.log
local vm_label="${OPT_VM_NAME:-unknown}"
# Strip path components if --vm was given a full path
vm_label=$(basename "$vm_label")
local new_log="$LOG_DIR/${vm_label}-$(date +%Y%m%d-%H%M%S).log"
if [[ "$LOG_FILE" != "$new_log" ]]; then
mv "$LOG_FILE" "$new_log" 2>/dev/null && LOG_FILE="$new_log"
fi
}
log_invocation_summary() {
local sep="════════════════════════════════════════════════════════════"
{
echo "$sep"
echo "vmrestore v$VMBACKUP_VERSION — $(date '+%Y-%m-%d %H:%M:%S')"
echo "$sep"
echo "Invocation: vmrestore.sh $ORIG_ARGS"
echo "Mode: ${OPT_MODE:-unset}"
echo "VM: ${OPT_VM_NAME:-unset}"
echo "Period: ${OPT_PERIOD:-auto}"
echo "Restore Point: ${OPT_RESTORE_POINT:-latest}"
echo "Restore Path: ${OPT_RESTORE_PATH:-unset}"
echo "Clone Name: ${OPT_NAME:-none (disaster recovery)}"
echo "Backup Path: ${OPT_BACKUP_PATH:-unset}"
echo "Disk Filter: ${OPT_DISK:-all}"
echo "No Pre-Restore: $OPT_NO_PRE_RESTORE"
echo "Skip Config: $OPT_SKIP_CONFIG"
echo "Skip TPM: $OPT_SKIP_TPM"
echo "Force: $OPT_FORCE"
echo "Dry Run: $OPT_DRY_RUN" # [DRY-RUN-KEEPER: display string, not a guard — see 109-phase7-spec.md §4 commit 1 acceptance + R3]
echo "Log File: $LOG_FILE"
echo "$sep"
} >> "$LOG_FILE"
}
log_completion_summary() {
local rc="$1"
local end_epoch
end_epoch=$(date +%s)
local elapsed=$(( end_epoch - START_EPOCH ))
local mins=$(( elapsed / 60 ))
local secs=$(( elapsed % 60 ))
local sep="════════════════════════════════════════════════════════════"
{
echo "$sep"
printf "Duration: %dm %ds\n" "$mins" "$secs"
echo "Exit Status: $rc"
echo "$sep"
} >> "$LOG_FILE"
# Also show to terminal
log_info "vmrestore.sh" "main" "Duration: ${mins}m ${secs}s — exit $rc — log: $LOG_FILE"
}
_log() {
# UNI-001: Removed in favour of lib/logging.sh log_msg(). Kept as a
# thin shim ONLY for any caller that still uses the old 3-arg form
# (level, fn, msg); transparently forwards to log_msg with the
# vmrestore.sh process tag. New code should call log_info / log_warn
# / log_error / log_debug directly with 3 args (process, fn, msg).
log_msg "$1" "vmrestore.sh" "$2" "$3"
}
# UNI-001: die() retains its (msg, fn, exitcode) signature; updated to
# pass the vmrestore.sh process tag through to lib/logging.sh log_error.
die() {
log_error "vmrestore.sh" "${2:-main}" "$1"
exit "${3:-$EXIT_ERROR}"
}
# Run a command, teeing all output (stdout+stderr) into the log file
# while still displaying on the terminal. Returns the command's exit code.
run_logged() {
"$@" 2>&1 | tee -a "$LOG_FILE"
return "${PIPESTATUS[0]}"
}
# INT-09 (2026-05-23): Human-readable size formatting — pure bash, no
# external dep. Mirrors vmbackup.sh::_format_size verbatim so output is
# byte-identical across the two scripts. Replaces 12 prior
# `numfmt --to=iec-i --suffix=B` sites (numfmt is provided by coreutils
# in Debian/Ubuntu but is missing in minimal containers and was an
# undeclared dependency).
_format_size() {
local bytes="${1:-0}"
if (( bytes >= 1073741824 )); then
local whole=$(( bytes / 1073741824 ))
local frac=$(( (bytes % 1073741824) * 10 / 1073741824 ))
printf "%d.%d GiB" "$whole" "$frac"
elif (( bytes >= 1048576 )); then
local whole=$(( bytes / 1048576 ))
local frac=$(( (bytes % 1048576) * 10 / 1048576 ))
printf "%d.%d MiB" "$whole" "$frac"
elif (( bytes >= 1024 )); then
local whole=$(( bytes / 1024 ))
local frac=$(( (bytes % 1024) * 10 / 1024 ))
printf "%d.%d KiB" "$whole" "$frac"
else
printf "%d B" "$bytes"
fi
}
# ── Configuration ────────────────────────────────────────────────────────────
resolve_backup_path() {
# Cascade: --backup-path CLI > vmbackup.conf (instance-aware)
if [[ -n "${BACKUP_PATH_CLI:-}" ]]; then
echo "$BACKUP_PATH_CLI"
return
fi
# UNI-012: Determine config instance and config file via lib helpers.
local instance
instance=$(resolve_config_instance "${OPT_CONFIG_INSTANCE:-}" "${VMBACKUP_INSTANCE:-}")
local conf
# R1: get_config_file validates the instance name; a non-zero return means
# an invalid/unsafe --config-instance and must be a hard failure (it would
# otherwise feed an attacker-influenced path to the resolver below).
if ! conf=$(get_config_file "$instance"); then
die "Invalid config instance '$instance' (allowed: letters, digits, . _ - ; no path separators)" "resolve_backup_path" "$EXIT_CONFIG"
fi
if [[ ! -f "$conf" && "$instance" != "default" ]]; then
die "Config instance '$instance' not found: $conf" "resolve_backup_path" "$EXIT_CONFIG"
fi
if [[ -f "$conf" ]]; then
# VMR3/X1: resolve BACKUP_PATH with the same shell semantics vmbackup
# uses (it sources the conf), not a regex scrape, so variable refs and
# spaces agree between backup and restore. $conf is the validated,
# in-tree conf (see get_config_file above).
local val
val=$(resolve_backup_path_shell "$conf" 2>/dev/null || true)
if [[ -n "$val" ]]; then
echo "$val"
return
fi
fi
die "Cannot resolve backup path. Use --backup-path or install vmbackup with a configured BACKUP_PATH in /opt/vmbackup/config/${instance}/vmbackup.conf" "resolve_backup_path" "$EXIT_CONFIG"
}
# ── Pre-flight Free Space Check ─────────────────────────────────────────────
# Sum the backup data files that will be restored and compare against
# available space on the destination filesystem. This prevents
# virtnbdrestore from silently producing truncated output on ENOSPC
# (upstream bug: virtnbdrestore exits 0 even when writes fail).
#
# Args: data_dir restore_path backup_type [until_checkpoint]
# Returns: 0 if OK, dies if insufficient space
preflight_free_space() {
local data_dir="$1" restore_path="$2" btype="$3" until_cp="${4:-}"
# Sum source data files (bytes)
local total_bytes=0
local file_count=0
local f
case "$btype" in
incremental)
# Include full + incrementals up to --until checkpoint
while IFS= read -r -d '' f; do
local basename
basename=$(basename "$f")
# If --until is set, skip files beyond that checkpoint
if [[ -n "$until_cp" ]]; then
local cp_name
# Extract checkpoint name: e.g. sda.inc.virtnbdbackup.3.data → virtnbdbackup.3
cp_name=$(echo "$basename" | grep -oP 'virtnbdbackup\.\d+' || true)
local until_num cp_num
until_num=${until_cp##*.}
cp_num=${cp_name##*.}
if [[ -n "$cp_num" && -n "$until_num" ]] && (( cp_num > until_num )); then
continue
fi
fi
local fsize
fsize=$(stat -c '%s' "$f" 2>/dev/null || echo 0)
total_bytes=$(( total_bytes + fsize ))
((file_count++))
done < <(find "$data_dir" -maxdepth 1 -type f \( \
-name "*.full.data" -o -name "*.inc.virtnbdbackup.*.data" \
\) -print0 2>/dev/null)
;;
full)
while IFS= read -r -d '' f; do
local fsize
fsize=$(stat -c '%s' "$f" 2>/dev/null || echo 0)
total_bytes=$(( total_bytes + fsize ))
((file_count++))
done < <(find "$data_dir" -maxdepth 1 -type f -name "*.full.data" -print0 2>/dev/null)
;;
copy)
while IFS= read -r -d '' f; do
local fsize
fsize=$(stat -c '%s' "$f" 2>/dev/null || echo 0)
total_bytes=$(( total_bytes + fsize ))
((file_count++))
done < <(find "$data_dir" -maxdepth 1 -type f -name "*.copy.data" -print0 2>/dev/null)
;;
esac
if (( file_count == 0 )); then
log_warn "vmrestore.sh" "preflight_free_space" "No data files found to estimate size — skipping space check"
return 0
fi
# Get available space on the destination filesystem
# Use the parent dir if restore_path doesn't exist yet
local check_path="$restore_path"
while [[ ! -d "$check_path" && "$check_path" != "/" ]]; do
check_path=$(dirname "$check_path")
done
local avail_bytes
avail_bytes=$(df --output=avail -B1 "$check_path" 2>/dev/null | tail -1 | tr -d ' ')
if [[ -z "$avail_bytes" || "$avail_bytes" == "0" ]]; then
log_warn "vmrestore.sh" "preflight_free_space" "Cannot determine free space on $check_path — skipping check"
return 0
fi
# Human-readable sizes
local total_hr avail_hr
total_hr=$(_format_size "$total_bytes")
avail_hr=$(_format_size "$avail_bytes")
log_info "vmrestore.sh" "preflight_free_space" "Backup data: $total_hr ($file_count files) — Destination free: $avail_hr ($check_path)"
if (( total_bytes > avail_bytes )); then
die "Insufficient space: restore needs $total_hr but only $avail_hr available on $check_path" "preflight_free_space" "$EXIT_STORAGE"
fi
# Warn if less than 10% headroom (restored qcow2 can be larger than raw data)
local headroom=$(( avail_bytes - total_bytes ))
local ten_pct=$(( total_bytes / 10 ))
if (( headroom < ten_pct )); then
log_warn "vmrestore.sh" "preflight_free_space" "Tight on space: only $(_format_size "$headroom") headroom after restore"
fi
}
# ── Backup Detection ────────────────────────────────────────────────────────
has_backup_data() {
# Avoid `find ... | grep -q .`: with `set -o pipefail`, grep closes the
# pipe after the first match and find dies with SIGPIPE (141), making
# the pipeline fail for any directory large enough that find has not
# yet finished writing — symptom observed on W22 with 138 .data files
# ("No backup data files in: <dir>" despite files being present).
# `-print -quit` makes find exit cleanly after the first match.
local _hit
_hit=$(find "$1" -maxdepth 1 -type f \( \
-name "*.full.data" -o \
-name "*.inc.virtnbdbackup.*.data" -o \
-name "*.copy.data" \
\) -print -quit 2>/dev/null)
[[ -n "$_hit" ]]
}
detect_backup_type() {
local dir="$1"
[[ -d "$dir" ]] || { echo "unknown"; return; }
local has_inc=false has_full=false has_copy=false
local f
while IFS= read -r -d '' f; do
case "$f" in
*.inc.virtnbdbackup.*.data) has_inc=true ;;
*.full.data) has_full=true ;;
*.copy.data) has_copy=true ;;
esac
done < <(find "$dir" -maxdepth 1 -type f -name '*.data' -print0 2>/dev/null)
if $has_inc; then echo "incremental"
elif $has_full; then echo "full"
elif $has_copy; then echo "copy"
else echo "unknown"
fi
}
is_accumulate() { has_backup_data "$1"; }
# ── Path Resolution ─────────────────────────────────────────────────────────
# List period subdirectories, newest first, sorted by actual *.data recency (see body).
# Delegates enumeration to lib/period.sh. Periodic-only by design — accumulate VMs are
# handled upstream by is_accumulate() / has_backup_data() and never reach this function.
list_periods() {
local vm_dir="$1"
# "Newest first" by ACTUAL RECENCY (newest *.data mtime within each period),
# NOT a lexical `sort -rV` on the period-id string. When rotation formats
# coexist — daily YYYYMMDD / monthly YYYYMM alongside weekly YYYY-Www, e.g.
# after a rotation-policy change or a normal rollover — version-sort ranks a
# same-year numeric id ABOVE the weekly id (a digit run outranks the "-W"),
# so `sort -rV` would elect a STALE numeric period over a newer weekly one and
# restore-latest would silently resolve to a wrong (older) restore point.
# Recency is format-agnostic and correct. Out-of-policy periods are left for
# retention / orphan-retention to age out — the restore path must NOT depend
# on their absence. Periods with no *.data get mtime 0 (sorted last); callers
# that require data still skip them via has_backup_data().
local _p _mt
while IFS= read -r _p; do
[[ -n "$_p" ]] || continue
_mt=$(find "$vm_dir/$_p" -maxdepth 1 -type f -name '*.data' -printf '%T@\n' 2>/dev/null \
| sort -rn | head -1)
printf '%s %s\n' "${_mt:-0}" "$_p"
done < <(get_vm_periods "$vm_dir") \
| sort -k1,1nr -k2,2Vr \
| awk '{print $2}'
}
# Resolve the directory containing .data files for a VM
# Accumulate: VM root. Period-based: specified or latest period.
# 118-spaces: resolve a VM's backup folder from its REAL libvirt name. Prefer the
# slug+hash token (vm_fs_name); fall back to the pre-118 legacy slug
# (vm_fs_name_legacy) for a folder that MIG-01 has not migrated yet (the rc3
# transition window). Echoes the existing dir (rc 0), or nothing (rc 1). -u-clean.
resolve_vm_backup_dir() {
local real="${1:-}" base="${2:-}"
local d tok
if tok=$(vm_fs_name "$real" 2>/dev/null); then
d="$base/$tok"
[[ -d "$d" ]] && { printf '%s' "$d"; return 0; }
fi
d="$base/$(vm_fs_name_legacy "$real")"
[[ -d "$d" ]] && { printf '%s' "$d"; return 0; }
return 1
}
resolve_data_dir() {
local vm_dir="$1" period="${2:-}"
# Explicit period always wins (even for accumulate VMs with period subdirs)
if [[ -n "$period" ]]; then
local target="$vm_dir/$period"
if [[ -d "$target" ]]; then
echo "$target"
else
log_error "vmrestore.sh" "resolve_data_dir" "Period not found: $target"
return 1
fi
return
fi
# No period specified.
# FF-192: a VM switched from accumulate to periodic keeps its VM-root *.data
# (rotation writes accumulate data at the root; migrate_layout renames folders
# only — nothing migrates root data), so testing is_accumulate FIRST let the
# stale root chain win forever and silently ignored newer period chains.
# Resolve by DATA RECENCY instead (consistent with list_periods): find the
# newest period that has data, then pick whichever of {VM root, that period}
# carries the newest *.data. Warn when both coexist so --period can override.
local _root_acc=false
is_accumulate "$vm_dir" && _root_acc=true
# Compute the recency-ordered period list ONCE (list_periods forks a find(1)
# per period) and reuse it for the no-data fallback below.
local -a _periods
mapfile -t _periods < <(list_periods "$vm_dir")
local _p _newest_period=""
for _p in "${_periods[@]}"; do
[[ -n "$_p" ]] || continue
if has_backup_data "$vm_dir/$_p"; then
_newest_period="$vm_dir/$_p"
break
fi
done
if [[ "$_root_acc" == true && -n "$_newest_period" ]]; then
# Mixed layout — pick newest by *.data mtime, warn either way.
local _root_mt _per_mt
_root_mt=$(find "$vm_dir" -maxdepth 1 -type f -name '*.data' -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
_per_mt=$(find "$_newest_period" -maxdepth 1 -type f -name '*.data' -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
_root_mt="${_root_mt%%.*}"; _root_mt="${_root_mt:-0}"
_per_mt="${_per_mt%%.*}"; _per_mt="${_per_mt:-0}"
if (( _per_mt > _root_mt )); then
log_warn "vmrestore.sh" "resolve_data_dir" "Mixed layout for '$(basename "$vm_dir")': VM-root accumulate data AND newer period data both present; restoring newest period '$(basename "$_newest_period")' (use --period to override)"
echo "$_newest_period"
else
log_warn "vmrestore.sh" "resolve_data_dir" "Mixed layout for '$(basename "$vm_dir")': VM-root accumulate data AND period subdirectories both present; restoring newest VM-root data (use --period to select a period)"
echo "$vm_dir"
fi
return
fi
# Pure accumulate — VM root.
if [[ "$_root_acc" == true ]]; then
echo "$vm_dir"
return
fi
# Pure periodic — newest period with data.
if [[ -n "$_newest_period" ]]; then
echo "$_newest_period"
return
fi
# Fallback: no period has data — return the newest dir anyway so the caller
# gets a meaningful error path.
if [[ -n "${_periods[0]:-}" ]]; then
echo "$vm_dir/${_periods[0]}"
else
log_error "vmrestore.sh" "resolve_data_dir" "No period directories in: $vm_dir"
return 1
fi
}
# ── Listing ──────────────────────────────────────────────────────────────────
list_vms() {
local backup_path="$1"
echo ""
echo "Available VMs in: $backup_path"
echo "══════════════════════════════════════════════════════════════"
# Presenter-scope counter (U1: reduce inside callback).
_LIST_VMS_FOUND=0
# Phase 4 (UNI-605 / USE-03): try once to open the vmbackup SQLite DB
# so _list_vms_vm_cb can enrich each VM block with a chain-health
# summary line. Memoised via _VMRESTORE_SQLITE_RO_INITED so a re-entrant
# list_vms call does not re-init. Failure is silent — chain-health line
# simply does not appear; the rest of --list output is unchanged from
# the pre-Phase-4 walker contract (USE-03: nice-to-have, not required).
if (( _VMRESTORE_SQLITE_RO_INITED == 0 )) && declare -f sqlite_init_readonly >/dev/null 2>&1; then
_VMRESTORE_SQLITE_RO_INITED=1
# Export BACKUP_PATH so sqlite_init_readonly can locate _state/.
BACKUP_PATH="$backup_path"
if sqlite_init_readonly 2>/dev/null; then
# Schema-version sanity check (Q-5 disposition). The DB may
# have been written by a newer vmbackup; if so, log a warning
# and continue without enrichment rather than risk garbage
# output from a missing/renamed column.
local _db_schema
_db_schema=$(sqlite_get_schema_version 2>/dev/null)
local _expected_major="2"
if [[ -n "$_db_schema" && "${_db_schema%%.*}" == "$_expected_major" ]]; then
_VMRESTORE_SQLITE_RO_OK=1
# Pre-fetch the per-VM chain-health summary once so the
# row callback is O(1) per VM (no per-VM sqlite3 fork).
declare -gA _VMRESTORE_CH_ACTIVE=()
declare -gA _VMRESTORE_CH_BROKEN=()
declare -gA _VMRESTORE_CH_LAST=()
local _ch_line _ch_vm _ch_active _ch_archived _ch_purged _ch_chk _ch_rest _ch_brk _ch_sz _ch_first _ch_last
while IFS='|' read -r _ch_vm _ch_active _ch_archived _ch_purged _ch_chk _ch_rest _ch_brk _ch_sz _ch_first _ch_last; do
[[ -z "$_ch_vm" || "$_ch_vm" == "vm_name" ]] && continue
# FF-165: the walker keys the row callback by the on-disk folder
# TOKEN (vm_fs_name slug+hash for any spaced/special-char name),
# but chain_health.vm_name is the REAL libvirt name. Key the map
# by vm_fs_name(real) so the $vm lookup at the "Chains:" line hits
# for spaced VMs (vm_fs_name is identity for fs-safe names).
local _ch_key
_ch_key=$(vm_fs_name "$_ch_vm" 2>/dev/null) || _ch_key="$_ch_vm"
[[ -n "$_ch_key" ]] || _ch_key="$_ch_vm"
_VMRESTORE_CH_ACTIVE["$_ch_key"]="${_ch_active:-0}"
_VMRESTORE_CH_BROKEN["$_ch_key"]="${_ch_brk:-0}"
_VMRESTORE_CH_LAST["$_ch_key"]="${_ch_last:-(never)}"
done < <(sqlite_query_chain_health "" pipe 2>/dev/null)
else
log_warn "vmrestore.sh" "list_vms" \
"DB schema $_db_schema unsupported by this vmrestore (expected major $_expected_major); chain-health enrichment disabled"
SQLITE_MODULE_AVAILABLE=0
fi
fi
fi
# UNI-309: walk the backup tree via lib/backup_walker.sh. The walker
# enforces VM-level skip-list (replaces the inline `[[ "$vm" == _state ]] &&
# continue` rule and adds `_*|.*` convergence with vmbackup). Period-level
# skip convergence in vmrestore is handled inside _list_vms_vm_cb via
# list_periods() → lib/period.sh, so we always return non-zero from
# vm_cb to suppress walker period iteration (the gather-then-print
# presenter shape has no clean streaming finalize hook in the skeletal
# walker, D2).
walk_backup_tree "$backup_path" _list_vms_vm_cb _list_vms_period_cb_unused
echo ""
echo "══════════════════════════════════════════════════════════════"
(( _LIST_VMS_FOUND == 0 )) && { log_warn "vmrestore.sh" "list_vms" "No backups found in $backup_path"; return 1; }
return 0
}
# Walker callback: present a single VM's restore-inventory block. Always
# returns non-zero so walk_backup_tree skips period iteration (handled
# internally via list_periods).
_list_vms_vm_cb() {
local vm="$1"
local vm_dir="$2"
local data_dir=""
local periods=()
local is_acc=false
# FF-192 (twin of resolve_data_dir): enumerate periods even when the VM root
# carries accumulate *.data, so a mixed layout (an accumulate→periodic switch
# leaves stale VM-root data behind) still surfaces the newer periods here
# instead of hiding them, and the displayed source tracks resolve_data_dir's
# data-recency choice.
local _root_acc=false
is_accumulate "$vm_dir" && _root_acc=true
mapfile -t periods < <(list_periods "$vm_dir")
# FF-164: pick the FIRST period that has data from the recency-sorted
# list_periods output — NOT a `stat -c %Y` max over period DIRECTORY mtimes,
# which any later write in an OLDER period (retention prune / archive collapse
# / checkpoint cleanup) skews, making --list render from the wrong period.
local _newest_period="" _p
for _p in "${periods[@]}"; do
[[ -n "$_p" ]] || continue
if has_backup_data "$vm_dir/$_p"; then
_newest_period="$vm_dir/$_p"
break
fi
done
if [[ "$_root_acc" == true && -n "$_newest_period" ]]; then
# Mixed layout: choose newest by *.data mtime (matches resolve_data_dir),
# keep periods populated so the "Periods:" line still lists them.
local _root_mt _per_mt
_root_mt=$(find "$vm_dir" -maxdepth 1 -type f -name '*.data' -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
_per_mt=$(find "$_newest_period" -maxdepth 1 -type f -name '*.data' -printf '%T@\n' 2>/dev/null | sort -rn | head -1)
_root_mt="${_root_mt%%.*}"; _root_mt="${_root_mt:-0}"
_per_mt="${_per_mt%%.*}"; _per_mt="${_per_mt:-0}"
if (( _per_mt > _root_mt )); then
data_dir="$_newest_period"
else
data_dir="$vm_dir"
is_acc=true
fi
elif [[ "$_root_acc" == true ]]; then
# Pure accumulate — VM root; no data-bearing periods to list.
data_dir="$vm_dir"
is_acc=true
periods=()
elif [[ -n "$_newest_period" ]]; then
# Pure periodic — newest period with data.
data_dir="$_newest_period"
elif [[ ${#periods[@]} -gt 0 ]]; then
# Periods exist but none has data yet (rotation before first backup);
# show the newest so the block still renders (unchanged from prior).
data_dir="$vm_dir/${periods[0]:-}"
else
return 1
fi
local btype size tpm_tag="" disk_tag=""
btype=$(detect_backup_type "$data_dir")
size=$(du -sh "$vm_dir" 2>/dev/null | awk '{print $1}')
[[ -f "$data_dir/.tpm-backup-marker" ]] && tpm_tag=" [TPM]"
# Show disk tags only for multi-disk VMs (latest CP's disks, not union)
local disks _latest_cp
_latest_cp=$(find "$data_dir/checkpoints" -name "virtnbdbackup.*.xml" 2>/dev/null \
| sed -n 's/.*virtnbdbackup\.\([0-9]*\)\.xml/\1/p' \
| sort -n | tail -1)
if [[ -n "$_latest_cp" ]]; then
# TODO(phase6/UNI-013): swap to lib/libvirt.sh
disks=$(enumerate_disks_at_checkpoint "$data_dir" "$_latest_cp")
else
# TODO(phase6/UNI-013): swap to lib/libvirt.sh
disks=$(enumerate_disks "$data_dir")
fi
[[ "$disks" == *,* ]] && disk_tag=" [$disks]"
# Count archives across all period dirs
local archive_count=0
local -A _seen_adirs=()
local -a _archive_search=("$vm_dir/.archives")
if [[ ${#periods[@]} -gt 0 ]]; then
local _p
for _p in "${periods[@]}"; do
_archive_search+=("$vm_dir/$_p/.archives")
done
else
_archive_search+=("$data_dir/.archives")
fi
local adir
for adir in "${_archive_search[@]}"; do
[[ -d "$adir" ]] || continue
local _real
_real=$(pu_normalise_path "$adir")
[[ -n "${_seen_adirs[$_real]:-}" ]] && continue
_seen_adirs[$_real]=1
archive_count=$(( archive_count + $(find "$adir" -maxdepth 1 -type d -name "chain-*" 2>/dev/null | wc -l) ))
done
# Count restore points across ALL active periods
local rpoints=0
if $is_acc; then
if [[ -d "$data_dir/checkpoints" ]]; then
rpoints=$(find "$data_dir/checkpoints" -name "virtnbdbackup.*.xml" 2>/dev/null | wc -l)
fi
[[ "$btype" =~ ^(full|copy)$ && $rpoints -eq 0 ]] && rpoints=1
else
local _p
for _p in "${periods[@]}"; do
local _pdir="$vm_dir/$_p"
if [[ -d "$_pdir/checkpoints" ]]; then
rpoints=$(( rpoints + $(find "$_pdir/checkpoints" -name "virtnbdbackup.*.xml" 2>/dev/null | wc -l) ))
else
local _ptype
_ptype=$(detect_backup_type "$_pdir")
[[ "$_ptype" =~ ^(full|copy)$ ]] && ((rpoints++))
fi
done
fi
# Build detail line with proper pluralisation
local p_word="points"; (( rpoints == 1 )) && p_word="point"
local detail="$btype · $rpoints $p_word"
if (( archive_count > 0 )); then
local a_word="archives"; (( archive_count == 1 )) && a_word="archive"
detail+=" · $archive_count $a_word"
fi
detail+="$disk_tag$tpm_tag"
printf "\n %-53s %6s\n" "$vm" "$size"
printf " %s\n" "$detail"
if [[ ${#periods[@]} -gt 0 ]]; then
printf " Periods: %s\n" "${periods[*]}"
fi
# Phase 4 (UNI-605 / USE-03): chain-health enrichment line when DB
# opened successfully in list_vms(). Format (Q-2 disposition):
# " Chains: <N> active, <N> broken, last backup <ISO timestamp>"
# Silent skip when DB unavailable or this VM has no DB rows —
# preserves the v0.5.4 standalone contract (vmrestore must work on
# any host with just the backup tree mounted).
if (( _VMRESTORE_SQLITE_RO_OK == 1 )) && [[ -n "${_VMRESTORE_CH_LAST[$vm]:-}" ]]; then
printf " Chains: %d active, %d broken, last backup %s\n" \
"${_VMRESTORE_CH_ACTIVE[$vm]:-0}" \
"${_VMRESTORE_CH_BROKEN[$vm]:-0}" \
"${_VMRESTORE_CH_LAST[$vm]:-(never)}"
fi
(( _LIST_VMS_FOUND++ )) || true
# Suppress walker period iteration; periods are handled internally above.
return 1
}
# Walker period-callback placeholder. Never invoked because
# _list_vms_vm_cb always returns non-zero (skip period iteration). Defined
# only so walk_backup_tree's invocation contract is satisfied.
_list_vms_period_cb_unused() {
return 0
}
# ── Disk Enumeration ─────────────────────────────────────────────────────────
# Scan a backup directory for unique device target names from .data files.
# Returns a sorted, comma-separated list (e.g., "sda, vda, vdb").
# Used by: --list-restore-points display, --disk validation, disk-restore mode.
enumerate_disks() {
local data_dir="$1"
local -A seen=();
local f fname dev
while IFS= read -r -d '' f; do
fname=$(basename "$f")
dev=""
case "$fname" in
*.full.data) dev="${fname%.full.data}" ;;
*.inc.virtnbdbackup.*.data) dev="${fname%%.*}" ;;
*.copy.data) dev="${fname%.copy.data}" ;;
esac
[[ -n "$dev" ]] && seen[$dev]=1
done < <(find "$data_dir" -maxdepth 1 -type f -name '*.data' -print0 2>/dev/null)
# Return sorted, comma-separated
printf '%s\n' "${!seen[@]}" | sort | paste -sd, | sed 's/,/, /g'
}
# Return sorted, comma-separated list of disks at a specific checkpoint number.
# Parses .data filenames: {dev}.full.data (CP 0), {dev}.inc.virtnbdbackup.{N}.data,
# {dev}.copy.data (CP 0). Used by: --list-restore-points, PIT staging trigger, --disk validation.
enumerate_disks_at_checkpoint() {
local data_dir="$1" cp_num="$2"
local -A seen=()
local f fname dev
while IFS= read -r -d '' f; do
fname=$(basename "$f")
dev=""
case "$fname" in
*.full.data)
[[ "$cp_num" == "0" ]] && dev="${fname%.full.data}" ;;
*.inc.virtnbdbackup."${cp_num}".data)
dev="${fname%%.*}" ;;
*.copy.data)