v0.6.0-rc1: slice 6 Phase A — backup + the self-restore-test (local target)

The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→
teardown) that closes "a backup you haven't restored isn't a backup". All benign
(reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local
target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent.

- proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID.
- reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down
  pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a
  leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent).
- internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence
  scheduler (4th daemon goroutine, default 24h) + in-memory report store.
- hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical +
  bidirectional key-set tests; hub handler logs a FAILED restore-test prominently.
- config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 13:49:39 +02:00
parent e548ab57fe
commit b527430ec7
23 changed files with 1727 additions and 46 deletions
+72
View File
@@ -27,9 +27,73 @@ type Config struct {
Authz AuthzConfig `json:"authz"`
Hub HubConfig `json:"hub"`
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// BackupConfig tunes the slice-6 backup + self-restore-test layer. The restore-test runs on
// an agent-internal cadence (no hub policy needed — it's self-validation); the backup
// schedule/retention/target-selection policy is hub-manifest-owned and unfed until slice 10.
type BackupConfig struct {
// LocalBackupTarget is the vzdump storage (content=backup) backups go to, e.g. "local".
LocalBackupTarget string `json:"local_backup_target"`
// RestoreStorage is where a restore-test's restored rootfs lands, e.g. "local-lvm".
RestoreStorage string `json:"restore_storage"`
// RestoreTestCadenceSeconds is the self-restore-test interval; 0 → default (24h).
// Set negative to DISABLE the automatic cadence (on-demand selftest still works).
RestoreTestCadenceSeconds int `json:"restore_test_cadence_seconds"`
// ScratchVMIDMin/Max bound the throwaway restore-test scratch-guest VMID band. The
// restore-test refuses to run unless this is a valid band (min>0, max>=min); 9999 is
// always excluded. Defaults to 990000990009.
ScratchVMIDMin int `json:"scratch_vmid_min"`
ScratchVMIDMax int `json:"scratch_vmid_max"`
}
// Default scratch VMID band + restore-test cadence.
const (
defaultScratchVMIDMin = 990000
defaultScratchVMIDMax = 990009
defaultRestoreTestCadence = 24 * time.Hour
)
// RestoreTestCadence returns the configured restore-test interval: a positive value as-is,
// 0 → 24h default, negative → 0 (disabled).
func (b BackupConfig) RestoreTestCadence() time.Duration {
switch {
case b.RestoreTestCadenceSeconds > 0:
return time.Duration(b.RestoreTestCadenceSeconds) * time.Second
case b.RestoreTestCadenceSeconds < 0:
return 0 // disabled
default:
return defaultRestoreTestCadence
}
}
// ScratchBand returns the effective [min,max] scratch VMID band (defaults applied).
func (b BackupConfig) ScratchBand() (min, max int) {
min, max = b.ScratchVMIDMin, b.ScratchVMIDMax
if min == 0 && max == 0 {
return defaultScratchVMIDMin, defaultScratchVMIDMax
}
return min, max
}
// ValidateForRestoreTest checks the scratch band is usable. Called only when the restore-test
// cadence is enabled (so a host that never restore-tests needn't configure a band).
func (b BackupConfig) ValidateForRestoreTest() error {
min, max := b.ScratchBand()
if min <= 0 || max < min {
return fmt.Errorf("config: backup.scratch_vmid_[min,max] is an invalid band [%d,%d]", min, max)
}
if 9999 >= min && 9999 <= max {
return fmt.Errorf("config: backup scratch band [%d,%d] must not include the standing scratch 9999", min, max)
}
if b.RestoreStorage == "" {
return fmt.Errorf("config: backup.restore_storage is required when the restore-test cadence is enabled")
}
return nil
}
// StorageConfig tunes the storage watchdog (slice 5). All optional — zero values fall back
// to the storage package defaults via the accessor methods. The watchdog poll is FAST
// (seconds) to catch a USB drop quickly; the debounce keeps a flapping drive from storming
@@ -203,6 +267,14 @@ func applyEnv(cfg *Config) {
}
cfg.Hub.PollSeconds = envInt("FELHOM_AGENT_HUB_POLL_SECONDS", cfg.Hub.PollSeconds)
cfg.Hub.TimeoutSeconds = envInt("FELHOM_AGENT_HUB_TIMEOUT_SECONDS", cfg.Hub.TimeoutSeconds)
// backup (slice 6)
if v := os.Getenv("FELHOM_AGENT_BACKUP_LOCAL_TARGET"); v != "" {
cfg.Backup.LocalBackupTarget = v
}
if v := os.Getenv("FELHOM_AGENT_BACKUP_RESTORE_STORAGE"); v != "" {
cfg.Backup.RestoreStorage = v
}
cfg.Backup.RestoreTestCadenceSeconds = envInt("FELHOM_AGENT_BACKUP_RESTORE_TEST_CADENCE_SECONDS", cfg.Backup.RestoreTestCadenceSeconds)
}
// envInt overlays an int env var, keeping cur (with a stderr warning) on parse