207 lines
7.4 KiB
Go
207 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|