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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user