diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d37508..057d842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ -## Unreleased (next: v0.167.0) — the BYO SSH clean-up in the SAME update (R-1285) (2026-10-26) — committed, not released +## Unreleased (next: v0.167.0) — the BYO SSH clean-up in the SAME update (R-1285); a reinstalled box's whole-guest tier is due at once (R-1232) — committed, not released + +- **R-1232 (P2):** `backup.BackupRunner.NewestArchiveTime` — the tier due-check — counts only archives written with THIS + storage's key on an encrypted storage (the R-727 discriminator; the archive carries no host id). Before: a reinstalled + box shares the customer's PBS namespace and guest number with the old box, the old box's archive read as „the last + backup", and the whole-guest tier waited up to its 168-h cadence (the 2026-10-23 test night). A key lookup failure is + an error (the due-check reads UNKNOWN), never a foreign age. Tests `TestR1232_*` (foreign only → none; own → its time; + an unencrypted dir → unfiltered), red-proved (case 1 returned the old box's time). - **The cause (measured 2026-10-26 on demo-hp, felhom.eu R-1285):** a box gets `agent_update` and `agent_config_update` in one pass; the bundle job runs in the OLD process (no policy pass in it) and the restart into the new agent comes a diff --git a/internal/backup/r1232_due_own_archives_test.go b/internal/backup/r1232_due_own_archives_test.go new file mode 100644 index 0000000..fa441d5 --- /dev/null +++ b/internal/backup/r1232_due_own_archives_test.go @@ -0,0 +1,55 @@ +package backup + +import ( + "context" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" +) + +// R-1232 — the 2026-10-23 test night: a reinstalled box (new install, new PBS key, same customer namespace, same guest +// number 9201) found the OLD box's archive (2.6 days old) when the due-check asked „when did this tier last land?", so +// the whole-guest tier read „not due" for up to its 168-h cadence and the ep0 copy stayed the OLD box's. The due-check +// now counts only archives written with THIS storage's key (the R-727 discriminator; the archive carries no host id). +// The CONSEQUENCE asserted: with only a foreign archive the answer is „none found" (the tier is due); with the box's +// own archive, that archive's time. COMPANION RED-PROOF: drop the key filter in NewestArchiveTime → case 1 returns the +// old box's time. +func TestR1232_TheDueCheckCountsOnlyThisBoxsArchives(t *testing.T) { + oldBox := proxmox.StorageContent{VolID: "felhom-pbsdr:backup/ct/9201/2026-10-20T21:10:00Z", Content: "backup", VMID: 9201, + Size: 3490689830, CTime: time.Date(2026, 10, 20, 21, 10, 0, 0, time.UTC).Unix(), Encrypted: earlierBox1} + api := &fakeBackupAPI{ + storages: []proxmox.Storage{{Storage: "felhom-pbsdr", Type: "pbs", EncryptionKey: thisBoxKey}}, + content: []proxmox.StorageContent{oldBox}, + } + r := NewBackupRunner(api, "felhom-pbsdr", proxmox.ModeSnapshot, "", "keep-last=1", quiet()) + + // 1. The fresh box has written nothing yet: the old box's archive must not count. + when, found, err := r.NewestArchiveTime(context.Background(), 9201) + if err != nil { + t.Fatal(err) + } + if found { + t.Fatalf("the due-check counted ANOTHER box's archive (%s) as this tier's last backup — the tier would wait a week", when) + } + // 2. After the box's own first copy, that copy is the answer. + own := proxmox.StorageContent{VolID: "felhom-pbsdr:backup/ct/9201/2026-10-24T02:30:00Z", Content: "backup", VMID: 9201, + Size: 3600000000, CTime: time.Date(2026, 10, 24, 2, 30, 0, 0, time.UTC).Unix(), Encrypted: thisBoxKey} + api.content = append(api.content, own) + when, found, err = r.NewestArchiveTime(context.Background(), 9201) + if err != nil || !found || when.Unix() != own.CTime { + t.Fatalf("want this box's own archive time %d, got %v found=%v err=%v", own.CTime, when, found, err) + } +} + +// An unencrypted storage (a local dir) holds only this box's vzdumps: no key filter, the newest counts. +func TestR1232_UnencryptedStorageCountsEveryArchive(t *testing.T) { + api := &fakeBackupAPI{ + storages: []proxmox.Storage{{Storage: "local", Type: "dir"}}, + content: []proxmox.StorageContent{{VolID: "local:backup/vzdump-lxc-9201-2026_10_25-21_27_05.tar.zst", Content: "backup", VMID: 9201, Size: 955425507, CTime: 1793050025}}, + } + r := NewBackupRunner(api, "local", proxmox.ModeSnapshot, "", "keep-last=1", quiet()) + if _, found, err := r.NewestArchiveTime(context.Background(), 9201); err != nil || !found { + t.Fatalf("an unencrypted local archive was not counted: found=%v err=%v", found, err) + } +} diff --git a/internal/backup/runner.go b/internal/backup/runner.go index db4f962..f14c662 100644 --- a/internal/backup/runner.go +++ b/internal/backup/runner.go @@ -609,11 +609,23 @@ func (r *BackupRunner) NewestArchiveTime(ctx context.Context, vmid int) (time.Ti if err != nil { return time.Time{}, false, err } + // R-1232: on an ENCRYPTED storage only archives written with THIS storage's key are this box's (the R-727 + // discriminator — the archive carries no host id). A reinstalled box shares the customer's namespace and guest + // number with the old box, and the old box's archive made the tier read „not due" for a whole cadence (168 h). + // A key lookup failure is returned: the due-check reads UNKNOWN, never a foreign archive's age. + ownKey, err := r.storageKeyFingerprint(ctx, r.target) + if err != nil { + return time.Time{}, false, fmt.Errorf("reading the key fingerprint of storage %s: %w", r.target, err) + } var best int64 = -1 for _, e := range contents { if e.Content != "backup" || e.VMID != vmid { continue } + if ownKey != "" && !strings.EqualFold(e.Encrypted, ownKey) { + r.noteNotAGuestBackupOnce(e, fmt.Sprintf("written by another box (key %s, this box's key %s) — not this tier's last backup (R-1232)", shortFP(e.Encrypted), shortFP(ownKey))) + continue + } if ok, why := archivePlausiblyComplete(e); !ok { r.warnRejectedArchiveOnce(e, why) continue