controller (unreleased): R-921 check-first pre-check (no stop while another tier's job is in flight); R-922 email_cleared on a household's deliberate clear
gates / gates (push) Successful in 1m0s
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -127,6 +127,33 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
|
||||
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
|
||||
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
|
||||
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
|
||||
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
|
||||
// backing up. It does NOT see the agent's host-wide heavy-operation gate: no endpoint serves it.
|
||||
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
|
||||
tiers, err := c.BackupTiers(ctx)
|
||||
if errors.Is(err, ErrTiersUnsupported) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, t := range tiers.Tiers {
|
||||
st, err := c.BackupStatusFor(ctx, t.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
|
||||
out = append(out, t.Target)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
||||
type SetBackupTargetResponse struct {
|
||||
Target string `json:"target"`
|
||||
|
||||
@@ -255,6 +255,9 @@ const (
|
||||
PhaseRunning = "running"
|
||||
PhaseDone = "done"
|
||||
PhaseFailed = "failed"
|
||||
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
|
||||
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
|
||||
PhaseSnapshotted = "snapshotted"
|
||||
)
|
||||
|
||||
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
|
||||
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
|
||||
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
|
||||
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
|
||||
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
|
||||
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
|
||||
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
tg := r.URL.Query().Get("target")
|
||||
ph, ok := phases[tg]
|
||||
if !ok {
|
||||
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BackupJobsInFlight: %v", err)
|
||||
}
|
||||
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
|
||||
// read as "cannot tell", and never a busy verdict.
|
||||
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
s := httptest.NewTLSServer(mux) // every route 404s
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A status read that fails is returned, so the loop can fail toward backing up and say so.
|
||||
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
|
||||
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user