3403c87c80
gates / gates (push) Successful in 34s
The guest's controller asks; the agent refuses in the first 10 min after boot, a second time in one boot (persisted, keyed by /proc/stat btime), and while a backup, restore test, OS step, controller update, drive format, NAS verify, escrow ceremony or vzdump runs (each a named 409 reason). On acceptance: the record first, a host report carrying deliberate_off accepted by the hub (bounded 60 s), then pct shutdown --timeout 120, then systemctl poweroff. Sudoers: two exact vectors (pct ^shutdown N --timeout N$, systemctl poweroff) in FELHOM_POWEROFF; nine injection cases. New TestEveryAliasIsGranted: a defined alias missing from the grant line passed the coverage check before. Every new test red-proofed.
218 lines
7.6 KiB
Go
218 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|