package vzpartial import ( "context" "errors" "io" "os" "os/exec" "path/filepath" "runtime" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) const vmid = 9201 const name = "vzdump-lxc-9201-2026_10_14-10_42_25" var boot = time.Unix(1791967481, 0) type fakeAPI struct{ stores []proxmox.Storage } func (f fakeAPI) ListStorage(context.Context) ([]proxmox.Storage, error) { return f.stores, nil } type fakeRunner struct { calls [][]string err error } func (r *fakeRunner) Run(_ context.Context, n string, a ...string) ([]byte, []byte, error) { r.calls = append(r.calls, append([]string{n}, a...)) if r.err != nil { return nil, []byte("REFUSED: in use"), r.err } return []byte("removed"), nil, nil } func (r *fakeRunner) RunStdin(ctx context.Context, _ io.Reader, n string, a ...string) ([]byte, []byte, error) { return r.Run(ctx, n, a...) } type rig struct { dump string runner *fakeRunner c *Cleaner } func newRig(t *testing.T, storeType string, inFlight bool, inErr error) *rig { t.Helper() root := t.TempDir() dump := filepath.Join(root, "dump") if err := os.MkdirAll(dump, 0o755); err != nil { t.Fatal(err) } r := &rig{dump: dump, runner: &fakeRunner{}} r.c = &Cleaner{ API: fakeAPI{stores: []proxmox.Storage{{Storage: "local", Type: storeType, Path: root}}}, Runner: r.runner, InFlight: func(context.Context, int) (bool, error) { return inFlight, inErr }, Boot: func() (int64, bool) { return boot.Unix(), true }, Now: func() time.Time { return boot.Add(5 * time.Minute) }, } return r } func (r *rig) put(t *testing.T, n string, dir bool, mt time.Time) { t.Helper() p := filepath.Join(r.dump, n) if dir { if err := os.MkdirAll(filepath.Join(p, "etc"), 0o755); err != nil { t.Fatal(err) } _ = os.Chtimes(filepath.Join(p, "etc"), mt, mt) } else if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } _ = os.Chtimes(p, mt, mt) } func (r *rig) asked() []string { var out []string for _, c := range r.runner.calls { if len(c) == 4 && c[0] == Helper && c[1] == "local" && c[2] == "9201" { out = append(out, c[3]) } } return out } // RED-PROOF: remove the `bootOK && e.ModTime.Before(boot)` rule in Dead → a pre-boot entry modified 1 min before the // boot is still „untouched for 6m" — so the test uses a 1-min-old-at-boot entry AND a Now 30 s after the boot. func TestClean_RemovesDeadPartialAndTmp(t *testing.T) { r := newRig(t, "dir", false, nil) r.c.Now = func() time.Time { return boot.Add(30 * time.Second) } r.put(t, name+".tar.dat", false, boot.Add(-time.Minute)) r.put(t, name+".tmp", true, boot.Add(-time.Minute)) res := r.c.Clean(context.Background(), "local", vmid) if got := r.asked(); len(got) != 2 { t.Fatalf("both dead entries must go to the helper, got %v", got) } for _, x := range res { if !x.Removed || x.Why != "written before this boot" { t.Fatalf("%+v", x) } } } func TestClean_RemovesQuietPartialOfThisBoot(t *testing.T) { r := newRig(t, "dir", false, nil) r.put(t, name+".tar.dat", false, boot.Add(time.Minute)) // 4 min before Now r.c.Clean(context.Background(), "local", vmid) if len(r.asked()) != 1 { t.Fatal("a partial untouched for QuietFor must be removed") } } // Each keep has its positive control (the dead pre-boot partial in the same rig IS asked for). func TestClean_Keeps(t *testing.T) { cases := map[string]func(t *testing.T) (*rig, string){ // RED-PROOF: drop the InFlight check in Clean → the in-flight partial is asked for → fails. "in flight": func(t *testing.T) (*rig, string) { return newRig(t, "dir", true, nil), name + ".tar.dat" }, "in-flight unknown": func(t *testing.T) (*rig, string) { return newRig(t, "dir", false, errors.New("api down")), name + ".tar.dat" }, // RED-PROOF: drop the `st.Type != "dir"` test → an lvmthin target is cleaned → fails. "not a dir storage": func(t *testing.T) (*rig, string) { return newRig(t, "lvmthin", false, nil), name + ".tar.dat" }, } for what, mk := range cases { r, n := mk(t) r.put(t, n, false, boot.Add(-time.Hour)) r.c.Clean(context.Background(), "local", vmid) if len(r.asked()) != 0 { t.Fatalf("%s: must keep, helper asked for %v", what, r.asked()) } ctl := newRig(t, "dir", false, nil) ctl.put(t, n, false, boot.Add(-time.Hour)) ctl.c.Clean(context.Background(), "local", vmid) if len(ctl.asked()) != 1 { t.Fatalf("%s: positive control not removed", what) } } } // RED-PROOF: weaken NamePattern to `vzdump-.*` → the finished archive / log / other guest's partial are asked for. func TestClean_OnlyThisGuestsHalfWrittenNames(t *testing.T) { r := newRig(t, "dir", false, nil) old := boot.Add(-time.Hour) for _, n := range []string{name + ".tar.zst", name + ".log", name + ".tar.zst.notes", "vzdump-lxc-9202-2026_10_14-10_42_25.tar.dat", "vzdump-lxc-92011-2026_10_14-10_42_25.tar.dat", name + ".tar.dat.bak", name + ".tar.da", "x" + name + ".tar.dat"} { r.put(t, n, false, old) } r.put(t, name+".tar.dat", false, old) // the positive control in the same directory r.c.Clean(context.Background(), "local", vmid) if got := r.asked(); len(got) != 1 || got[0] != name+".tar.dat" { t.Fatalf("only %s.tar.dat may be asked for, got %v", name, got) } } func TestClean_KeepsRecentAndWrongKindAndSymlink(t *testing.T) { r := newRig(t, "dir", false, nil) r.put(t, name+".tar.dat", false, boot.Add(4*time.Minute)) // 1 min before Now: may still be written r.put(t, "vzdump-lxc-9201-2026_10_14-11_00_00.tmp", false, boot.Add(-time.Hour)) // a FILE named .tmp r.put(t, "vzdump-lxc-9201-2026_10_14-13_00_00.tar.dat", false, boot.Add(-time.Hour)) // positive control r.c.Clean(context.Background(), "local", vmid) if got := r.asked(); len(got) != 1 || got[0] != "vzdump-lxc-9201-2026_10_14-13_00_00.tar.dat" { t.Fatalf("only the positive control may be asked for, got %v", got) } } // RED-PROOF: drop the ModeSymlink skip in Candidates → the symlink (quiet: Now is an hour ahead) is asked for → fails. func TestClean_KeepsSymlink(t *testing.T) { r := newRig(t, "dir", false, nil) r.c.Now = func() time.Time { return time.Now().Add(time.Hour) } target := filepath.Join(r.dump, "elsewhere") _ = os.WriteFile(target, []byte("x"), 0o644) _ = os.Symlink(target, filepath.Join(r.dump, "vzdump-lxc-9201-2026_10_14-12_00_00.tar.dat")) r.put(t, "vzdump-lxc-9201-2026_10_14-13_00_00.tar.dat", false, boot.Add(-time.Hour)) // positive control r.c.Clean(context.Background(), "local", vmid) if got := r.asked(); len(got) != 1 || got[0] != "vzdump-lxc-9201-2026_10_14-13_00_00.tar.dat" { t.Fatalf("only the positive control may be asked for, got %v", got) } } func TestClean_HelperRefusalIsNotARemoval(t *testing.T) { r := newRig(t, "dir", false, nil) r.runner.err = errors.New("exit status 1") r.put(t, name+".tar.dat", false, boot.Add(-time.Hour)) res := r.c.Clean(context.Background(), "local", vmid) if len(res) != 1 || res[0].Removed { t.Fatalf("a refused helper call must not count as removed: %+v", res) } } // The root helper's own tests (configs/test_felhom_vzdump_partial_clean.py) run with the Go suite. func TestRootHelperPythonSuite(t *testing.T) { py, err := exec.LookPath("python3") if err != nil { t.Skip("python3 not found") } _, file, _, _ := runtime.Caller(0) script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_vzdump_partial_clean.py") out, err := exec.Command(py, script).CombinedOutput() if err != nil { t.Fatalf("helper suite failed: %v\n%s", err, out) } }