slice 8B (agent half): /backup/due cadence policy + /backup/status phases (v0.11.0)

internal/localapi: real /backup/due (cadence; due when no successful backup or
newest older than backup.backup_cadence_seconds; false in-window after success;
failed doesn't count) + /backup/status phases (idle|running|done|failed + job
id) + POST /backup single-flight with job id. Drives the controller quiesce loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:44:50 +02:00
parent e51b3a2f66
commit 33dfd9afb3
6 changed files with 352 additions and 54 deletions
+144 -24
View File
@@ -64,14 +64,24 @@ func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions
}
type fakeBackups struct {
mu sync.Mutex
vmids []int
mu sync.Mutex
vmids []int
gate chan struct{} // if non-nil, Backup blocks until it is closed (observe the running phase)
failErr string // if set, Backup returns this error (drives the failed phase)
}
func (f *fakeBackups) Backup(_ context.Context, vmid int) (hub.Backup, error) {
f.mu.Lock()
f.vmids = append(f.vmids, vmid)
gate := f.gate
failErr := f.failErr
f.mu.Unlock()
if gate != nil {
<-gate
}
if failErr != "" {
return hub.Backup{VMID: vmid}, fmt.Errorf("%s", failErr)
}
return hub.Backup{VMID: vmid, Success: true, Archive: "local:backup/vzdump-x", StartedAt: "2026-06-10T00:00:00Z"}, nil
}
func (f *fakeBackups) called() []int { f.mu.Lock(); defer f.mu.Unlock(); return append([]int(nil), f.vmids...) }
@@ -106,25 +116,35 @@ func (m staticTokens) Lookup(tok string) (int, bool) { v, ok := m[tok]; return v
// ---- harness ----------------------------------------------------------------------------
func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler {
// testNow is the fixed clock the test server uses, so /backup/due cadence math is deterministic.
var testNow = time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
func newTestServerS(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) *Server {
t.Helper()
if sv == nil {
sv = fakeStorage{}
}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: g,
Backups: b,
Store: st,
Storage: sv,
Tokens: staticTokens{"A": 8200, "B": 9300},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
ListenAddr: "127.0.0.1:0",
Guests: g,
Backups: b,
Store: st,
Storage: sv,
Tokens: staticTokens{"A": 8200, "B": 9300},
BackupCadence: 24 * time.Hour,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
srv.baseCtx = context.Background()
return srv.Handler()
srv.now = func() time.Time { return testNow }
return srv
}
func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler {
t.Helper()
return newTestServerS(t, g, b, st, sv).Handler()
}
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
@@ -304,26 +324,126 @@ func TestBackup_EnqueuesForTokenGuest(t *testing.T) {
}
}
func TestBackupDue_ThinHeuristic(t *testing.T) {
st := &fakeStore{}
h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)
// no backup recorded → due
func dueOf(t *testing.T, h http.Handler) BackupDueResponse {
t.Helper()
w := do(t, h, "GET", "/backup/due", "A", "")
var resp struct {
Data BackupDueResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Data.Due {
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode due: %v", err)
}
return resp.Data
}
// testNow is 2026-06-10T12:00:00Z, cadence 24h.
func TestBackupDue_Cadence(t *testing.T) {
st := &fakeStore{}
h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)
// no backup recorded → due
if d := dueOf(t, h); !d.Due {
t.Fatal("expected due=true with no backup recorded")
}
// a successful backup for this guest → not due
st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T00:00:00Z"}}
w = do(t, h, "GET", "/backup/due", "A", "")
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Data.Due {
t.Fatal("expected due=false after a successful backup")
// a successful backup 1h ago → NOT due (within the 24h window)
st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z"}}
if d := dueOf(t, h); d.Due {
t.Fatalf("expected due=false 1h after a successful backup; reason=%q", d.Reason)
}
// a successful backup >24h ago → due again
st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-08T00:00:00Z"}}
if d := dueOf(t, h); !d.Due {
t.Fatal("expected due=true when the newest backup is older than the cadence")
}
// a FAILED backup 1h ago does NOT satisfy the cadence → still due
st.backups = []hub.Backup{{VMID: 8200, Success: false, StartedAt: "2026-06-10T11:00:00Z"}}
if d := dueOf(t, h); !d.Due {
t.Fatal("expected due=true: a failed backup must not count as a successful one")
}
}
// POST /backup returns a running phase; the job transitions running→done; a successful backup then
// flips /backup/due to false (the controller won't re-quiesce in a loop).
func TestBackupStatus_RunningToDone(t *testing.T) {
st := &fakeStore{}
b := &fakeBackups{}
srv := newTestServerS(t, &fakeGuests{}, b, st, nil)
h := srv.Handler()
// gate the backup goroutine so we can observe the running phase deterministically
b.gate = make(chan struct{})
w := do(t, h, "POST", "/backup", "A", "")
if w.Code != http.StatusAccepted {
t.Fatalf("POST /backup: got %d want 202", w.Code)
}
var br struct {
Data BackupResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &br)
if br.Data.Phase != PhaseRunning || br.Data.JobID == "" {
t.Fatalf("expected running phase + job id, got %+v", br.Data)
}
if ph := statusPhase(t, h); ph != PhaseRunning {
t.Fatalf("status while running: got %q want running", ph)
}
close(b.gate) // let the backup complete
// wait for done
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if statusPhase(t, h) == PhaseDone {
break
}
time.Sleep(5 * time.Millisecond)
}
if ph := statusPhase(t, h); ph != PhaseDone {
t.Fatalf("status after completion: got %q want done", ph)
}
}
func TestBackupStatus_RunningToFailed(t *testing.T) {
b := &fakeBackups{failErr: "vzdump exploded"}
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
h := srv.Handler()
if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted {
t.Fatal("POST /backup not accepted")
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if statusPhase(t, h) == PhaseFailed {
break
}
time.Sleep(5 * time.Millisecond)
}
if ph := statusPhase(t, h); ph != PhaseFailed {
t.Fatalf("status after a failed backup: got %q want failed", ph)
}
}
// A second POST /backup while one is running does NOT start a second vzdump (single-flight).
func TestBackup_SingleFlight(t *testing.T) {
b := &fakeBackups{gate: make(chan struct{})}
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
h := srv.Handler()
do(t, h, "POST", "/backup", "A", "")
do(t, h, "POST", "/backup", "A", "") // should be coalesced onto the running job
close(b.gate)
time.Sleep(30 * time.Millisecond)
if c := b.called(); len(c) != 1 {
t.Fatalf("expected a single vzdump for concurrent POSTs, got %d", len(c))
}
}
func statusPhase(t *testing.T, h http.Handler) string {
t.Helper()
w := do(t, h, "GET", "/backup/status", "A", "")
var resp struct {
Data BackupStatusResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
return resp.Data.Phase
}
func TestBackupStatus_FiltersToThisGuest(t *testing.T) {