R-1124 + R-1012: forget a decommissioned drive that is gone; name an orphan thin device in a backup failure
gates / gates (push) Successful in 47s
gates / gates (push) Successful in 47s
R-1124: ForgetRetiredDrives (start-up + the 20 s tick) disables and removes the host mount unit of a DECOMMISSIONED drive whose disk is absent and unmounted; the agent then no longer lists it. A present decommissioned drive keeps its unit (one-click re-enroll). The file goes only through the new felhom-priv-apply verb `unit-forget`, which re-checks marker, disabled, unmounted and absent as root. R-1012: the read-only root helper felhom-thin-check <vg> <pool> (bundle) lists the pool's thin ids (metadata snapshot reserved and released) next to LVM's before and after. BackupRunner.SetThinCheck runs it before a snapshot-mode backup (log) and again after a failure, appending the finding in plain words to the error the controller mails to the operator. Two reads, intersected; fails open; never removes. Red-proofs: felhom.eu audits/agent-allowlist-rotation-2026-10-17/P1/. Code only; the CHANGELOG entry follows the release. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
344c4f9030
commit
e15d1d30ea
15 files changed
+1116
-5
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// R-1012: a snapshot backup that fails carries the orphan finding in its error — the text the controller mails to
|
||||
// the operator (`whole_guest_backup_failed`). The check runs once before the run and once, FRESH, after the failure.
|
||||
// RED-PROOF (P1/redproof-thincheck.txt): with explainFailure's append removed this test fails.
|
||||
func TestBackup_SnapshotFailure_NamesTheOrphan(t *testing.T) {
|
||||
const why = "probable cause: thin pool pve/data holds device 4 that LVM does not know"
|
||||
for name, api := range map[string]*fakeBackupAPI{
|
||||
"task fails": {vzdumpUPID: "UPID:x", waitErr: errors.New("task failed: job errors")},
|
||||
"start fails": {vzdumpErr: errors.New("vzdump: 500")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||
calls := 0
|
||||
r.SetThinCheck(func(context.Context, int) string { calls++; return why })
|
||||
rec, err := r.Backup(context.Background(), 9201)
|
||||
if err == nil || rec.Success {
|
||||
t.Fatalf("want a failure, got %+v", rec)
|
||||
}
|
||||
if !strings.Contains(rec.Error, why) {
|
||||
t.Fatalf("the failure does not name the orphan: %q", rec.Error)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("thin check ran %d times, want 2 (before the run and after the failure)", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A successful backup keeps a clean record (the pre-check only logs); a stop-mode backup takes no snapshot, so the
|
||||
// check never runs; an unwired runner behaves exactly as before.
|
||||
func TestBackup_ThinCheck_SilentWhenNotApplicable(t *testing.T) {
|
||||
ok := &fakeBackupAPI{vzdumpUPID: "UPID:x",
|
||||
content: []proxmox.StorageContent{{VolID: "local:backup/vzdump-lxc-9201-x", Content: "backup", VMID: 9201, Size: 100, CTime: 1}}}
|
||||
r := NewBackupRunner(ok, "local", "", "", "", quiet())
|
||||
calls := 0
|
||||
r.SetThinCheck(func(context.Context, int) string { calls++; return "probable cause: x" })
|
||||
rec, err := r.Backup(context.Background(), 9201)
|
||||
if err != nil || !rec.Success || rec.Error != "" || calls != 1 {
|
||||
t.Fatalf("success: err=%v rec=%+v calls=%d (want one pre-check, clean record)", err, rec, calls)
|
||||
}
|
||||
|
||||
stop := NewBackupRunner(&fakeBackupAPI{vzdumpUPID: "UPID:x", waitErr: errors.New("boom")}, "local", proxmox.ModeStop, "", "", quiet())
|
||||
calls = 0
|
||||
stop.SetThinCheck(func(context.Context, int) string { calls++; return "probable cause: x" })
|
||||
rec, _ = stop.Backup(context.Background(), 9201)
|
||||
if calls != 0 || strings.Contains(rec.Error, "probable cause") {
|
||||
t.Fatalf("stop mode: calls=%d error=%q", calls, rec.Error)
|
||||
}
|
||||
|
||||
plain := NewBackupRunner(&fakeBackupAPI{vzdumpUPID: "UPID:x", waitErr: errors.New("boom")}, "local", "", "", "", quiet())
|
||||
if rec, _ := plain.Backup(context.Background(), 9201); rec.Error != "boom" {
|
||||
t.Fatalf("unwired: error = %q, want the task error unchanged", rec.Error)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,12 @@ type BackupRunner struct {
|
||||
// beforeBackup runs just before the space preflight (R-1006): it removes a dead half-written vzdump of this guest on
|
||||
// this target, so a leftover from a power cut cannot hold the space this backup needs. nil = nothing.
|
||||
beforeBackup func(ctx context.Context, storage string, vmid int)
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
// thinCheck (R-1012) names an orphan thin device in the guest's pools in plain words ("" = none / not checked). It
|
||||
// runs before a snapshot-mode backup (a WARN in the log) and again when the vzdump fails, so the failure the
|
||||
// controller mails to the operator says why. It never removes anything. nil = not wired.
|
||||
thinCheck func(ctx context.Context, vmid int) string
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
// rejected remembers the volids already announced by warnRejectedArchiveOnce, so an incomplete
|
||||
// archive is reported ONCE rather than on every 5-minute due-check. Bounded in practice: one
|
||||
// entry per aborted upload, and a process restart clears it. Guarded by rejectedMu because the
|
||||
@@ -110,6 +114,22 @@ func (r *BackupRunner) SetBeforeBackup(fn func(ctx context.Context, storage stri
|
||||
r.beforeBackup = fn
|
||||
}
|
||||
|
||||
// SetThinCheck wires the R-1012 orphan check (see BackupRunner.thinCheck).
|
||||
func (r *BackupRunner) SetThinCheck(fn func(ctx context.Context, vmid int) string) { r.thinCheck = fn }
|
||||
|
||||
// explainFailure appends the orphan finding to a failed snapshot-mode backup's error. A FRESH check (not the one from
|
||||
// before the run): the failure itself may be what made the orphan. Bounded, so a stuck helper cannot hold the job.
|
||||
func (r *BackupRunner) explainFailure(ctx context.Context, vmid int, rec *hub.Backup) {
|
||||
if r.thinCheck == nil || r.mode != proxmox.ModeSnapshot {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
if why := r.thinCheck(cctx, vmid); why != "" {
|
||||
rec.Error = rec.Error + " — " + why
|
||||
}
|
||||
}
|
||||
|
||||
// localPruneSpec returns the `--prune-backups` spec to apply to THIS backup, or "" to skip pruning. It
|
||||
// applies the configured retention ONLY when the target is a non-PBS storage — PBS offsite retention is a
|
||||
// separate lifecycle and must never be pruned by the per-run flag (§9). Fail-safe: if the target's type
|
||||
@@ -201,12 +221,18 @@ func (r *BackupRunner) backup(ctx context.Context, vmid int, onSnapshot func())
|
||||
return rec, fmt.Errorf("backup: %s", rec.Error)
|
||||
}
|
||||
|
||||
if r.thinCheck != nil && r.mode == proxmox.ModeSnapshot {
|
||||
if why := r.thinCheck(ctx, vmid); why != "" {
|
||||
r.logger.Warn("backup: an orphan thin device may make this snapshot backup fail (R-1012)", "vmid", vmid, "finding", why)
|
||||
}
|
||||
}
|
||||
upid, err := r.api.Vzdump(ctx, proxmox.VzdumpOptions{
|
||||
VMID: vmid, Storage: r.target, Mode: r.mode, Notes: r.notes,
|
||||
PruneBackups: r.localPruneSpec(ctx), // local target → keep-last=N; PBS/unknown → "" (no prune)
|
||||
})
|
||||
if err != nil {
|
||||
rec.Error = err.Error()
|
||||
r.explainFailure(ctx, vmid, &rec)
|
||||
rec.DurationSeconds = time.Since(start).Seconds()
|
||||
return rec, fmt.Errorf("backup: vzdump vmid %d: %w", vmid, err)
|
||||
}
|
||||
@@ -220,6 +246,7 @@ func (r *BackupRunner) backup(ctx context.Context, vmid int, onSnapshot func())
|
||||
}
|
||||
if _, err := r.api.WaitTask(ctx, upid, proxmox.WaitOptions{Timeout: r.waitTimeout}); err != nil {
|
||||
rec.Error = err.Error()
|
||||
r.explainFailure(ctx, vmid, &rec)
|
||||
rec.DurationSeconds = time.Since(start).Seconds()
|
||||
return rec, fmt.Errorf("backup: vzdump task vmid %d: %w", vmid, err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user