package reconcile import ( "context" "encoding/json" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // R-834: a whole-guest restore BESIDE a live original must never come up as a second box on the same // drives. The DR route keeps `onboot: 1`, binds the real drives and starts the guest, so it refuses // when the original (or any guest binding the drives) is still on this host; on a replaced host it // proceeds and keeps its binds. Red-proof: make liveOriginalBeside return "" and the refusals pass // the restore through (the "refused" sub-tests fail). func TestRunBringUp_DRRefusesBesideALiveOriginal(t *testing.T) { const target = 9299 drivesBind := proxmox.GuestConfig{Extra: map[string]json.RawMessage{ "mp8": json.RawMessage(`"/mnt/felhom-drives,mp=/mnt/felhom-drives"`), }} cases := []struct { name string archive string lxc []proxmox.Guest cfg map[int]proxmox.GuestConfig refuse string // substring of the refusal; "" = must proceed }{ {"the source guest still exists", "local:backup/vzdump-lxc-9201-2026_10_04-04_34_55.tar.zst", []proxmox.Guest{{VMID: 9201, Status: "running"}}, map[int]proxmox.GuestConfig{9201: scratchCfg()}, "source guest 9201"}, {"another guest binds the drives (PBS archive)", "felhom-pbs:backup/ct/9201/2026-10-04T02:34:55Z", []proxmox.Guest{{VMID: 9300, Status: "stopped"}}, map[int]proxmox.GuestConfig{9300: drivesBind}, "binds the household drives"}, {"a guest whose config cannot be read", "local:backup/vzdump-lxc-9201-x.tar.zst", []proxmox.Guest{{VMID: 9400, Status: "running"}}, map[int]proxmox.GuestConfig{}, "could not be read"}, {"replaced host: only an unrelated scratch guest", "local:backup/vzdump-lxc-9201-x.tar.zst", []proxmox.Guest{{VMID: 9202, Status: "running"}}, map[int]proxmox.GuestConfig{9202: scratchCfg()}, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { cfg := c.cfg cfg[target] = scratchCfg() api := &fakeAPI{lxc: c.lxc, cfg: cfg} e, fr, _, q := newDREngine(t, api) defer q.Close() res := e.RunBringUp(context.Background(), BringUpSpec{ Mode: ModeDRGuestLoss, Archive: c.archive, VMID: target, RestoreStorage: "local-lvm", KeepMAC: true, }) if c.refuse != "" { if res.Err == nil || !strings.Contains(res.Err.Error(), c.refuse) || !strings.Contains(res.Err.Error(), "R-834") { t.Fatalf("want a refusal naming %q, got %+v", c.refuse, res) } if len(api.restores) != 0 || len(api.starts) != 0 || len(fr.cmds) != 0 { t.Fatalf("a refused DR touched the host: restores=%d starts=%v cmds=%v", len(api.restores), api.starts, fr.cmds) } return } if res.Err != nil || !res.Pass { t.Fatalf("a DR on a replaced host must proceed, got %+v", res) } // … and there it keeps the REAL drives bind (the right binds on a replaced host). joined := strings.Join(fr.cmds, "\n") if !strings.Contains(joined, "-mp8 /mnt/felhom-drives,mp=/mnt/felhom-drives") { t.Fatalf("the DR guest lost its drives bind: %v", fr.cmds) } }) } } // Provisioning restores the GOLDEN (no drives, onboot set by the back-half on purpose): a drives- // binding guest on the host does not block it — the rule is DR's alone. func TestRunBringUp_ProvisionNotBlockedByADrivesBind(t *testing.T) { api := &fakeAPI{ lxc: []proxmox.Guest{{VMID: 9201, Status: "running"}}, cfg: map[int]proxmox.GuestConfig{ 9201: {Extra: map[string]json.RawMessage{"mp8": json.RawMessage(`"/mnt/felhom-drives,mp=/mnt/felhom-drives"`)}}, 9203: scratchCfg(), }, } e, _, q := newEngine(t, api, EmptyProvider{}) defer q.Close() res := e.RunBringUp(context.Background(), BringUpSpec{Mode: ModeProvision, Archive: "local:vztmpl/felhom-golden.tar.zst", VMID: 9203, RestoreStorage: "local-lvm"}) if res.Err != nil || !res.Pass { t.Fatalf("provision must proceed, got %+v", res) } } func TestArchiveSourceVMID(t *testing.T) { for in, want := range map[string]int{ "local:backup/vzdump-lxc-9201-2026_10_04-04_34_55.tar.zst": 9201, "felhom-pbs:backup/ct/9201/2026-10-04T02:34:55Z": 9201, "tmp-dooplex-copy:backup/ct/9201/2026-10-03T19:00:00Z": 9201, "local:vztmpl/felhom-golden.tar.zst": 0, "vol": 0, } { if got := archiveSourceVMID(in); got != want { t.Errorf("archiveSourceVMID(%q) = %d, want %d", in, got, want) } } } // R-834, the restore-test route: its scratch guest sits BESIDE the live original by design, so it must // carry no host-path bind — the archive's mp8 (the household's drives) and mp9 (the original's // bootstrap) are replaced by throwaway volumes AT restore time. Measured live 2026-10-04 on demo-hp // (`audits/backup-close-2026-10-04/partA/`): onboot 0 and no host bind on every poll. Red-proof: // make drRestoreOverrides return the archive's own mp8 value and this fails. func TestRestoreTest_NoHostPathBindBesideTheOriginal(t *testing.T) { api := &fakeAPI{ cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}, extractCfg: "hostname: demo-hp\nonboot: 1\nrootfs: local-lvm:vm-9201-disk-0,size=16G\n" + "mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/felhom,backup=1,size=70G\n" + "mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives\n" + "mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1\n", } e, _, q := newEngine(t, api, EmptyProvider{}) defer q.Close() _ = e.RunRestoreTest(context.Background(), RestoreTestSpec{ Archive: "local:backup/vzdump-lxc-9201-x.tar.zst", RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local", }) if len(api.restores) != 1 { t.Fatalf("want one restore, got %+v", api.restores) } r := api.restores[0] for _, slot := range []string{"mp8", "mp9"} { v, ok := r.MountOverrides[slot] if !ok || strings.HasPrefix(v, "/") { t.Fatalf("%s = %q (present=%v): the scratch beside the original must get a throwaway volume, never the host path", slot, v, ok) } } for slot, v := range r.MountOverrides { if strings.HasPrefix(v, "/") { t.Fatalf("%s carries a host path %q into the scratch guest", slot, v) } } if r.ConfigOverrides["onboot"] != "0" { t.Fatalf("onboot = %q, want 0", r.ConfigOverrides["onboot"]) } }