package localapi import ( "context" "encoding/json" "net/http" "path/filepath" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/backup" "gitea.dooplex.hu/admin/felhom-agent/internal/poweroff" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // fakePower records the power-off steps in order. type fakePower struct { mu sync.Mutex steps []string } func (f *fakePower) ShutdownGuest(_ context.Context, vmid int, timeout time.Duration) error { f.mu.Lock() defer f.mu.Unlock() f.steps = append(f.steps, "shutdown:"+itoaT(vmid)+":"+timeout.String()) return nil } func (f *fakePower) PowerOffHost(context.Context) error { f.mu.Lock() defer f.mu.Unlock() f.steps = append(f.steps, "poweroff") return nil } func (f *fakePower) got() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.steps...) } func itoaT(n int) string { b, _ := json.Marshal(n); return string(b) } type fakeVzdump struct{ running bool } func (f fakeVzdump) Guests(context.Context) ([]proxmox.Guest, error) { return nil, nil } func (f fakeVzdump) Lock(context.Context, int) (string, bool, error) { return "", true, nil } func (f fakeVzdump) BackupRunning(context.Context, int) (bool, error) { return f.running, nil } func (f fakeVzdump) Unlock(context.Context, int) error { return nil } func (f fakeVzdump) Start(context.Context, int) error { return nil } func (f fakeVzdump) HasVzdumpSnapshot(context.Context, int) (bool, error) { return false, nil } func (f fakeVzdump) DeleteVzdumpSnapshot(context.Context, int) error { return nil } type powerRig struct { srv *Server h http.Handler power *fakePower state *poweroff.State report *[]string } func newPowerRig(t *testing.T, uptime time.Duration) *powerRig { t.Helper() srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) srv.inFlight = &backup.InFlight{} p := &fakePower{} st := poweroff.New(filepath.Join(t.TempDir(), "poweroff.json"), func() string { return "1700000000" }) var reports []string srv.SetHostPowerOff(HostPowerOffConfig{ State: st, Power: p, ReportNow: func(context.Context) bool { // The record must already exist when the hub is told — that is the point of telling it. if st.Current() == nil { reports = append(reports, "report-without-record") } else if len(p.got()) != 0 { reports = append(reports, "report-after-"+p.got()[0]) } else { reports = append(reports, "report") } return true }, Uptime: func() (time.Duration, bool) { return uptime, true }, Now: func() time.Time { return testNow }, }) return &powerRig{srv: srv, h: srv.Handler(), power: p, state: st, report: &reports} } func (r *powerRig) waitSteps(t *testing.T, n int) []string { t.Helper() for i := 0; i < 200 && len(r.power.got()) < n; i++ { time.Sleep(time.Millisecond) } return r.power.got() } func reasonOf(t *testing.T, body []byte) string { t.Helper() var resp struct { Data struct { Reason string `json:"reason"` } `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("body %s: %v", body, err) } return resp.Data.Reason } // The whole path: 202, the record written, the hub told FIRST, then guest shutdown (120 s), then host off. func TestHostPowerOff_AcceptedOrder(t *testing.T) { r := newPowerRig(t, time.Hour) w := do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusAccepted { t.Fatalf("got %d want 202 (%s)", w.Code, w.Body.String()) } steps := r.waitSteps(t, 2) want := []string{"shutdown:8200:2m0s", "poweroff"} if len(steps) != 2 || steps[0] != want[0] || steps[1] != want[1] { t.Fatalf("steps = %v, want %v", steps, want) } if len(*r.report) != 1 || (*r.report)[0] != "report" { t.Fatalf("hub report = %v, want one report carrying the record", *r.report) } if rec := r.state.Current(); rec == nil || rec.VMID != 8200 { t.Fatalf("record = %+v", rec) } } // Refused during a backup — the shared heavy-operation gate held by a backup tier. func TestHostPowerOff_RefusedDuringBackup(t *testing.T) { r := newPowerRig(t, time.Hour) release, _, ok := r.srv.inFlight.TryAcquire("backup:primary") if !ok { t.Fatal("could not hold the gate") } defer release() w := do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffBackup { t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffBackup) } time.Sleep(5 * time.Millisecond) if s := r.power.got(); len(s) != 0 { t.Fatalf("power steps ran during a backup: %v", s) } if r.state.Current() != nil { t.Fatal("a refused request must not use up the boot's one request") } // The same guard names a restore test and an OS step. release() for gate, want := range map[string]string{"restore-test": PowerOffRestoreTest, "os-pve-step": PowerOffOSUpdate} { rel, _, _ := r.srv.inFlight.TryAcquire(gate) w := do(t, r.h, "POST", "/host/poweroff", "A", "") rel() if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != want { t.Fatalf("%s: got %d %s, want 409 %s", gate, w.Code, w.Body.String(), want) } } } // A vzdump of the guest in flight (a Proxmox task, not the agent's own gate) refuses too. func TestHostPowerOff_RefusedDuringVzdump(t *testing.T) { r := newPowerRig(t, time.Hour) r.srv.staleLock = fakeVzdump{running: true} w := do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffBackup { t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffBackup) } } // One accepted request per boot: the second is refused and nothing runs twice. func TestHostPowerOff_SecondRequestSameBootRefused(t *testing.T) { r := newPowerRig(t, time.Hour) if w := do(t, r.h, "POST", "/host/poweroff", "A", ""); w.Code != http.StatusAccepted { t.Fatalf("first: %d", w.Code) } r.waitSteps(t, 2) w := do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffAlreadyAsked { t.Fatalf("second: got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffAlreadyAsked) } time.Sleep(5 * time.Millisecond) if s := r.power.got(); len(s) != 2 { t.Fatalf("steps = %v, want exactly one run", s) } } // Refused in the first 10 minutes after boot; an unknown uptime refuses too (fail closed). func TestHostPowerOff_TooSoonAfterBoot(t *testing.T) { r := newPowerRig(t, 9*time.Minute) w := do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffTooSoon { t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffTooSoon) } r.srv.powerOff.Uptime = func() (time.Duration, bool) { return 0, false } w = do(t, r.h, "POST", "/host/poweroff", "A", "") if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffUnknown { t.Fatalf("unknown uptime: got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffUnknown) } } // Not configured → 503; another guest's vmid in the body → 403; no token → 401. func TestHostPowerOff_ScopeAndConfig(t *testing.T) { plain := newTestServer(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) if w := do(t, plain, "POST", "/host/poweroff", "A", ""); w.Code != http.StatusServiceUnavailable { t.Fatalf("unconfigured: %d", w.Code) } r := newPowerRig(t, time.Hour) if w := do(t, r.h, "POST", "/host/poweroff", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden { t.Fatalf("cross-guest: %d", w.Code) } if w := do(t, r.h, "POST", "/host/poweroff", "", ""); w.Code != http.StatusUnauthorized { t.Fatalf("no token: %d", w.Code) } if r.state.Current() != nil || len(r.power.got()) != 0 { t.Fatal("a refused request changed state") } }