475bdce7e4
gates / gates (push) Successful in 18s
The DR route keeps onboot 1, binds the real drives and starts the guest: right on a replaced host, a second box on the same drives beside a live original. The restore-test's no-host-bind half is now pinned too (measured safe live on demo-hp 2026-10-04). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
139 lines
6.1 KiB
Go
139 lines
6.1 KiB
Go
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"])
|
|
}
|
|
}
|