From d347dc48d2ee9b4dca4b4ec9fa4958ec4d9b833a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 11 Jul 2026 13:45:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20agent-capability=20gate=20for=20coupled?= =?UTF-8?q?=20features=20=E2=80=94=20typed=20StatusError=20+=20Supports=20?= =?UTF-8?q?probe/cache=20+=20netstorage=20add=20gate=20(option-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agentapi: non-2xx GETs now surface as typed *StatusError (same text); features.go adds Feature/SupportState/SupportCache (route probe, TTL 5m, Yes/No cached, Unknown never cached or refused) + Client.Supports - web: handleNetStorageAdd refuses up front (412, code agent_outdated, Hungarian message) when the agent predates /netstorage/verify-status (= pre-0.81 add semantics); gate runs BEFORE the single-flight claim; SupportUnknown passes through to the existing agent-error paths - netAddSupport page-render helper lands here; its template consumer follows - tests: T1 gate refusal (job never starts, slot free), T2 unchanged happy path + warm-cache negative assertion, T3 indeterminate never 'too old', T4 classification incl. the string-match trap, T6 TTL, wire-level 404-typing; red-proofs RP1-RP4 run and reverted Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- controller/internal/agentapi/client.go | 16 +- controller/internal/agentapi/features.go | 126 +++++++++++++ controller/internal/agentapi/features_test.go | 164 +++++++++++++++++ .../internal/web/netstorage_gate_test.go | 168 ++++++++++++++++++ .../internal/web/netstorage_handlers.go | 35 ++++ controller/internal/web/server.go | 3 + 6 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 controller/internal/agentapi/features.go create mode 100644 controller/internal/agentapi/features_test.go create mode 100644 controller/internal/web/netstorage_gate_test.go diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 99843cf..14f8f04 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -26,6 +26,8 @@ type Client struct { baseURL string token string hc *http.Client + // features caches capability-probe verdicts for Supports (features.go). + features SupportCache } // MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6). @@ -853,6 +855,18 @@ func (c *Client) HostMetrics(ctx context.Context) (HostMetricsResponse, error) { return out, nil } +// StatusError is a non-2xx agent HTTP status surfaced as a TYPED error (same text the old +// fmt.Errorf produced). errors.As-able — the capability probe (features.go) keys on Code 404 to +// distinguish "this agent predates the route" from every other failure. Never match the string. +type StatusError struct { + Path string + Code int +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("agentapi: GET %s: HTTP %d", e.Path, e.Code) +} + // get issues an authenticated GET and unwraps the {ok,data,error} envelope. func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) @@ -867,7 +881,7 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("agentapi: GET %s: HTTP %d", path, resp.StatusCode) + return nil, &StatusError{Path: path, Code: resp.StatusCode} } var env apiResponse if err := json.Unmarshal(raw, &env); err != nil { diff --git a/controller/internal/agentapi/features.go b/controller/internal/agentapi/features.go new file mode 100644 index 0000000..d0dd335 --- /dev/null +++ b/controller/internal/agentapi/features.go @@ -0,0 +1,126 @@ +package agentapi + +import ( + "context" + "errors" + "net/http" + "sync" + "time" +) + +// Agent-capability probing (the publish-train backstop). A controller release that depends on +// coupled agent behavior must not fail mid-pipeline against an older agent — it detects support up +// front and refuses honestly. Detection is a ROUTE PROBE: a route that shipped together with the +// coupled semantics either answers (2xx ⇒ supported) or 404s (older agent). Transport errors and +// 5xx are INDETERMINATE — an agent problem is never claimed as "too old". + +// Feature names one coupled controller↔agent capability. +type Feature string + +// FeatureNetstorageVerify is the NAS verify-before-commit add semantics (agent v0.81.0): the +// coupled add behavior shipped together with GET /netstorage/verify-status, so that route IS the +// capability signal. +const FeatureNetstorageVerify Feature = "netstorage_verify" + +// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never +// refuses — the existing agent-error paths speak honestly when the agent is down). +type SupportState int + +const ( + // SupportUnknown — the probe could not decide (transport error, timeout, auth, 5xx). + SupportUnknown SupportState = iota + // SupportYes — the agent answered 2xx on the feature's probe route. + SupportYes + // SupportNo — the agent answered 404: it predates the route, and with it the coupled semantics. + SupportNo +) + +// SupportProber is the minimal agent surface a probe needs. *Client satisfies it, and so does the +// web layer's netAgent seam — tests inject fakes there. +type SupportProber interface { + NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error) +} + +// featureProbes maps each coupled feature to its route probe. +// +// CONVENTION (publish-train rules doc, felhom.eu/documentation/runbooks/publish-train-rules.md): +// every future coupled feature adds a row here plus a Supports gate call at its entry point, and +// declares MinAgent in its CHANGELOG header. When the agent someday reports an explicit version in +// its envelope, Supports should prefer that version comparison over route probing — that +// enhancement is roadmap, not built yet. +var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error{ + FeatureNetstorageVerify: func(ctx context.Context, p SupportProber) error { + _, err := p.NetVerifyStatus(ctx) + return err + }, +} + +// supportTTL bounds how long a probe verdict (either polarity) is trusted. An agent updated +// mid-window flips within this — no invalidation plumbing by design. +const supportTTL = 5 * time.Minute + +type supportEntry struct { + state SupportState + at time.Time +} + +// SupportCache caches per-feature probe verdicts. Yes and No are cached for supportTTL; Unknown is +// NEVER cached (a down agent re-probes on the next call, so recovery is immediate). The zero value +// is ready to use. +type SupportCache struct { + mu sync.Mutex + now func() time.Time // test seam; nil → time.Now + entries map[Feature]supportEntry +} + +// Supports reports whether the agent behind p provides feature f, answering from the cache inside +// the TTL window and probing otherwise. The probe runs OUTSIDE the lock — concurrent misses may +// double-probe (harmless: the probe is one cheap GET). +func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature) SupportState { + probe, ok := featureProbes[f] + if !ok { + return SupportUnknown // unregistered feature — never refuse on a table gap + } + sc.mu.Lock() + nowFn := sc.now + if nowFn == nil { + nowFn = time.Now + } + if e, hit := sc.entries[f]; hit && nowFn().Sub(e.at) < supportTTL { + sc.mu.Unlock() + return e.state + } + sc.mu.Unlock() + + state := classifySupportErr(probe(ctx, p)) + if state != SupportUnknown { + sc.mu.Lock() + if sc.entries == nil { + sc.entries = map[Feature]supportEntry{} + } + sc.entries[f] = supportEntry{state: state, at: nowFn()} + sc.mu.Unlock() + } + return state +} + +// Supports probes (cached, TTL 5m, both polarities) whether the connected agent provides the +// feature. 2xx ⇒ Yes. 404 ⇒ No. Anything else ⇒ Unknown (never "too old"). The web layer drives +// the same machinery through its netAgent seam (Server.netFeatures) so tests can fake the probe. +func (c *Client) Supports(ctx context.Context, f Feature) SupportState { + return c.features.Supports(ctx, c, f) +} + +// classifySupportErr maps a probe outcome to a SupportState. ONLY a typed HTTP 404 means +// "unsupported" — every other error (transport, timeout, 401, 5xx, envelope problems) is Unknown, +// so a merely-down agent is never reported as outdated. Typed errors only; never string-match. +func classifySupportErr(err error) SupportState { + if err == nil { + return SupportYes + } + var se *StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return SupportNo + } + return SupportUnknown +} diff --git a/controller/internal/agentapi/features_test.go b/controller/internal/agentapi/features_test.go new file mode 100644 index 0000000..1ea73d0 --- /dev/null +++ b/controller/internal/agentapi/features_test.go @@ -0,0 +1,164 @@ +package agentapi + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeProber scripts the capability probe (the SupportProber seam) and counts calls. +type fakeProber struct { + err error + calls int +} + +func (f *fakeProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) { + f.calls++ + return NetVerifyStatus{Phase: "none"}, f.err +} + +// --- T4: probe classification — ONLY a typed 404 means "too old" ------------------------------- +// Companion red-proof (the classification trap): mutate classifySupportErr to string-match +// "HTTP 404" → the "plain error with 404 text" case classifies No → FAIL. A second mutant treating +// ANY error as SupportNo fails every Unknown case here (and T3 in web). +func TestSupports_Classification(t *testing.T) { + cases := []struct { + name string + err error + want SupportState + }{ + {"2xx (nil error)", nil, SupportYes}, + {"typed 404", &StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound}, SupportNo}, + {"typed 404 wrapped", fmt.Errorf("probe: %w", &StatusError{Path: "/x", Code: 404}), SupportNo}, + {"typed 401", &StatusError{Path: "/x", Code: http.StatusUnauthorized}, SupportUnknown}, + {"typed 500", &StatusError{Path: "/x", Code: http.StatusInternalServerError}, SupportUnknown}, + {"typed 502", &StatusError{Path: "/x", Code: http.StatusBadGateway}, SupportUnknown}, + {"connection refused", errors.New("dial tcp 10.0.0.9:8443: connect: connection refused"), SupportUnknown}, + {"timeout", context.DeadlineExceeded, SupportUnknown}, + // The string-matching trap: the OLD untyped error text carries "HTTP 404" but is NOT a + // *StatusError — a classifier that matches the message would wrongly say "too old". + {"plain error with 404 text", errors.New("agentapi: GET /netstorage/verify-status: HTTP 404"), SupportUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sc := &SupportCache{} + got := sc.Supports(context.Background(), &fakeProber{err: tc.err}, FeatureNetstorageVerify) + if got != tc.want { + t.Errorf("Supports(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +// Unknown feature names must never refuse (a table gap fails open). +func TestSupports_UnknownFeature(t *testing.T) { + sc := &SupportCache{} + p := &fakeProber{} + if got := sc.Supports(context.Background(), p, Feature("no_such_feature")); got != SupportUnknown { + t.Errorf("unknown feature = %v, want SupportUnknown", got) + } + if p.calls != 0 { + t.Errorf("unknown feature must not probe (calls=%d)", p.calls) + } +} + +// --- T6: cache TTL — both polarities cached; Unknown NEVER cached ------------------------------ +// Companion red-proof: drop the TTL expiry check (treat every entry as fresh) → the +// "re-probes after TTL" assertion fails. Dropping the cache entirely → the warm-cache negative +// assertion (calls stays 1) fails (T2's red-proof shape). +func TestSupports_CacheTTL(t *testing.T) { + t.Run("positive cached, re-probes after TTL", func(t *testing.T) { + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + sc := &SupportCache{now: func() time.Time { return now }} + p := &fakeProber{} // nil err → Yes + if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes { + t.Fatalf("first = %v, want Yes", got) + } + if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes { + t.Fatalf("warm = %v, want Yes", got) + } + if p.calls != 1 { // the NEGATIVE assertion: a warm cache must NOT re-probe + t.Errorf("probe calls on a warm cache = %d, want 1", p.calls) + } + now = now.Add(supportTTL + time.Second) + if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes { + t.Fatalf("post-TTL = %v, want Yes", got) + } + if p.calls != 2 { + t.Errorf("probe calls after TTL expiry = %d, want 2 (must re-fire)", p.calls) + } + }) + t.Run("negative cached too", func(t *testing.T) { + now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + sc := &SupportCache{now: func() time.Time { return now }} + p := &fakeProber{err: &StatusError{Path: "/x", Code: 404}} + for i := 0; i < 2; i++ { + if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportNo { + t.Fatalf("call %d = %v, want No", i, got) + } + } + if p.calls != 1 { + t.Errorf("negative verdict not cached (calls=%d, want 1)", p.calls) + } + }) + t.Run("unknown never cached", func(t *testing.T) { + sc := &SupportCache{} + p := &fakeProber{err: errors.New("connection refused")} + for i := 0; i < 2; i++ { + if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportUnknown { + t.Fatalf("call %d = %v, want Unknown", i, got) + } + } + if p.calls != 2 { + t.Errorf("Unknown must re-probe every call (calls=%d, want 2)", p.calls) + } + }) +} + +// --- 1.1 wire-level: a real HTTP 404 through Client.get IS the typed StatusError ---------------- +// This pins the verify-first finding: an agent without the route (≤0.80's plain mux 404) reaches +// classifySupportErr as *StatusError{404}, end-to-end through the pinned-TLS client. +func TestClient_404IsTypedAndSupportsSaysNo(t *testing.T) { + mux := http.NewServeMux() // NO /netstorage/verify-status route — the ≤0.80 shape + srv := httptest.NewTLSServer(mux) + defer srv.Close() + fp := sha256.Sum256(srv.Certificate().Raw) + c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:])) + if err != nil { + t.Fatalf("New: %v", err) + } + _, verr := c.NetVerifyStatus(context.Background()) + var se *StatusError + if !errors.As(verr, &se) || se.Code != http.StatusNotFound { + t.Fatalf("404 must surface as *StatusError{404}, got %T: %v", verr, verr) + } + if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportNo { + t.Errorf("Supports on a routeless agent = %v, want SupportNo", got) + } +} + +// The supported shape: the route answers the envelope → Supports says Yes. +func TestClient_SupportsYesOnLiveRoute(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/netstorage/verify-status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"data":{"phase":"none"}}`)) // the no-job envelope + }) + srv := httptest.NewTLSServer(mux) + defer srv.Close() + fp := sha256.Sum256(srv.Certificate().Raw) + c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:])) + if err != nil { + t.Fatalf("New: %v", err) + } + if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportYes { + t.Errorf("Supports on a live route = %v, want SupportYes", got) + } +} diff --git a/controller/internal/web/netstorage_gate_test.go b/controller/internal/web/netstorage_gate_test.go new file mode 100644 index 0000000..d433cd5 --- /dev/null +++ b/controller/internal/web/netstorage_gate_test.go @@ -0,0 +1,168 @@ +package web + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" +) + +// Capability-gate tests (agent-outdated backstop): the probe runs through the SAME netAgent seam +// as the orchestration — fakeNetAgent's NetVerifyStatus doubles as the probed route. + +// counts reads the fake's recorded call counters under its lock. +func (f *fakeNetAgent) counts() (adds, verifyCalls int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.addCalls, f.verifyPoll +} + +// setAddRes swaps the scripted add result between requests (under the fake's lock). +func (f *fakeNetAgent) setAddRes(res agentapi.NetStorageAddResult) { + f.mu.Lock() + f.addRes = res + f.mu.Unlock() +} + +// postNetAdd drives the REAL handler (the pipeline a user triggers) with a valid body. +func postNetAdd(t *testing.T, s *Server, name string) *httptest.ResponseRecorder { + t.Helper() + body := `{"name":"` + name + `","protocol":"nfs","server":"10.0.0.5","export":"/srv/` + name + `"}` + r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body)) + w := httptest.NewRecorder() + s.handleNetStorageAdd(w, r) + return w +} + +// --- T1 (Scenario A): old agent ⇒ sync refusal; the job NEVER starts --------------------------- +// Companion red-proof: remove the gate call in handleNetStorageAdd → the job starts against the +// old add semantics → the "never started" + zero-add-calls assertions fail. +func TestNetAddGate_OldAgent_RefusedUpFront(t *testing.T) { + s := testServer(t) + // The ≤0.80 shape: the probed route 404s (typed — the wire path is pinned in agentapi tests). + agent := &fakeNetAgent{ + addRes: okAddRes("media"), + verifyErr: &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound}, + } + s.netAgentFn = func() (netAgent, error) { return agent, nil } + s.netProbeFn = func(context.Context, string) probeOutcome { t.Error("probe must never run on a gated add"); return probeOutcome{} } + + w := postNetAdd(t, s, "media") + if w.Code != http.StatusPreconditionFailed { + t.Fatalf("gate: got %d want 412 (%s)", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"code":"agent_outdated"`) { + t.Errorf("refusal must carry the machine code agent_outdated: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Az ügynök frissítése szükséges ehhez a funkcióhoz") { + t.Errorf("refusal must carry the §Part-3 Hungarian message: %s", w.Body.String()) + } + // The orchestration was NEVER started and no agent add/remove ran. + if j := s.netAdd.snapshot(); j != nil { + t.Errorf("job slot must stay empty on a gated add, got %+v", j) + } + adds, _ := agent.counts() + if adds != 0 { + t.Errorf("agent add calls = %d, want 0", adds) + } + if got := agent.removed(); len(got) != 0 { + t.Errorf("no rollback should ever run (removes=%v)", got) + } + if got := networkPathCount(s); got != 0 { + t.Errorf("nothing may register on a gated add (got %d)", got) + } + // The single-flight slot was NEVER claimed — it must still be acquirable. + if !s.netAdd.acquire(&netAddJob{Name: "slot-check"}) { + t.Error("single-flight slot was consumed by the refused add") + } + s.netAdd.release() +} + +// --- T2 (Scenario B): current agent ⇒ pre-gate behavior + ONE probe per cache window ------------ +// Companion red-proof: drop the cache in SupportCache.Supports (always probe) → the warm-cache +// negative assertion (verify calls stays 1) fails with 2. +func TestNetAddGate_CurrentAgent_UnchangedAndProbeCached(t *testing.T) { + s := testServer(t) + // Pre-verify-shaped OK result (Verify != "started"): the orchestrator skips the verify poll, so + // the fake's NetVerifyStatus count measures the GATE PROBE alone. + res1 := okAddRes("m1") + res1.Verify, res1.JobID = "", "" + agent := &fakeNetAgent{addRes: res1, verify: agentapi.NetVerifyStatus{Phase: "none"}} + s.netAgentFn = func() (netAgent, error) { return agent, nil } + s.netProbeFn = func(context.Context, string) probeOutcome { return probeOutcome{OK: true} } + + // Add #1 — identical end-to-end shape to the pre-gate happy path (C1's contract). + w := postNetAdd(t, s, "m1") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"started":true`) { + t.Fatalf("add #1: got %d %s, want 200 started", w.Code, w.Body.String()) + } + if job := waitNetAdd(t, s); job.Phase != netAddPhaseDone { + t.Fatalf("add #1 phase = %s (category=%s detail=%s), want done", job.Phase, job.Category, job.Detail) + } + if got := networkPathCount(s); got != 1 { + t.Fatalf("registered paths after add #1 = %d, want 1", got) + } + if got := agent.removed(); len(got) != 0 { + t.Fatalf("happy path must not roll back: %v", got) + } + + // Add #2 inside the TTL window — the probe must answer from the cache. + res2 := okAddRes("m2") + res2.Verify, res2.JobID = "", "" + agent.setAddRes(res2) + w = postNetAdd(t, s, "m2") + if w.Code != http.StatusOK { + t.Fatalf("add #2: got %d (%s)", w.Code, w.Body.String()) + } + if job := waitNetAdd(t, s); job.Phase != netAddPhaseDone || job.Name != "m2" { + t.Fatalf("add #2 job = %+v, want done/m2", job) + } + adds, verifyCalls := agent.counts() + if adds != 2 { + t.Errorf("agent add calls = %d, want 2", adds) + } + if verifyCalls != 1 { // the NEGATIVE assertion: no probe increment on a warm cache + t.Errorf("probe (verify-status) calls across two adds = %d, want exactly 1 (cached)", verifyCalls) + } +} + +// --- T3 (Scenario C): probe indeterminate ⇒ NEVER "too old"; the existing error paths speak ----- +// Companion red-proof (the classification trap): mutate classifySupportErr to return SupportNo on +// ANY error → both subtests fail with the false 412 agent_outdated refusal. +func TestNetAddGate_ProbeIndeterminate_PassesThrough(t *testing.T) { + cases := map[string]error{ + "transport error": errors.New("dial tcp 10.0.0.9:8443: connect: connection refused"), + "http 5xx": &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusBadGateway}, + } + for name, perr := range cases { + t.Run(name, func(t *testing.T) { + s := testServer(t) + agent := &fakeNetAgent{ + verifyErr: perr, + addErr: errors.New("agentapi: POST /netstorage/add: connection refused"), + } + s.netAgentFn = func() (netAgent, error) { return agent, nil } + + w := postNetAdd(t, s, "media") + // The gate must NOT refuse: the add is accepted and fails through the EXISTING + // agent-error path with its honest message. + if w.Code != http.StatusOK { + t.Fatalf("indeterminate probe must pass the gate: got %d (%s)", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "agent_outdated") || strings.Contains(w.Body.String(), "frissítés") { + t.Fatalf("a down agent must never be called outdated: %s", w.Body.String()) + } + job := waitNetAdd(t, s) + if job.Phase != netAddPhaseFailed || job.Category != "agent_error" { + t.Errorf("job = %s/%s, want failed/agent_error (the existing path)", job.Phase, job.Category) + } + if got := networkPathCount(s); got != 0 { + t.Errorf("nothing may register (got %d)", got) + } + }) + } +} diff --git a/controller/internal/web/netstorage_handlers.go b/controller/internal/web/netstorage_handlers.go index 2a8eecb..0afdec9 100644 --- a/controller/internal/web/netstorage_handlers.go +++ b/controller/internal/web/netstorage_handlers.go @@ -22,6 +22,31 @@ import ( // agent applies the +100000 host offset; this is the in-guest id the share is mapped to. const defaultMediaUID = 1000 +// netAddOutdatedMsg is the sync add-time refusal (machine code "agent_outdated") when the agent +// predates the coupled verify-before-commit add semantics (pre-v0.81.0). +const netAddOutdatedMsg = "Az ügynök frissítése szükséges ehhez a funkcióhoz — a frissítés megérkezése után próbáld újra." + +// netAddSupport evaluates the coupled-feature probe for the settings-page render with a SHORT +// budget — a down agent must not stall the page (the cache usually answers instantly). Returns the +// template vocabulary: "yes" | "no" | "unknown"; only "no" swaps the add form for the banner — +// flaky states belong to the add-time handling. +func (s *Server) netAddSupport() string { + agent, err := s.netAgentForAdd() + if err != nil { + return "unknown" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + switch s.netFeatures.Supports(ctx, agent, agentapi.FeatureNetstorageVerify) { + case agentapi.SupportYes: + return "yes" + case agentapi.SupportNo: + return "no" + default: + return "unknown" + } +} + // networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent. // Orphan marks an agent-configured share with NO registry entry (a crash-window leftover — Scenario // F's visible closure): the row renders with ONLY the remove action. @@ -91,6 +116,16 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } + // Capability gate (the publish-train backstop): the coupled add semantics shipped with agent + // v0.81.0 together with GET /netstorage/verify-status — on an older agent, refuse up front + // instead of failing mid-pipeline in `verifying` with a misleading rollback. Runs BEFORE the + // single-flight claim (a refused add must not consume the slot). SupportUnknown passes: a down + // agent speaks through the existing agent-error paths, never as "too old". + if s.netFeatures.Supports(r.Context(), agent, agentapi.FeatureNetstorageVerify) == agentapi.SupportNo { + s.logger.Printf("[WARN] [web] netstorage add %q refused: agent predates %s (probe 404)", name, agentapi.FeatureNetstorageVerify) + writeDiskJSON(w, http.StatusPreconditionFailed, false, netAddOutdatedMsg, map[string]any{"code": "agent_outdated"}) + return + } label := strings.TrimSpace(req.Label) if label == "" { label = "Hálózati tárhely: " + name diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 618ad63..5785bb8 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -74,6 +74,9 @@ type Server struct { netAgentFn func() (netAgent, error) netProbeFn func(ctx context.Context, dir string) probeOutcome netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error) + // netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add + // semantics — the add gate + the settings-page banner read it. Zero value ready. + netFeatures agentapi.SupportCache // Asset syncer for Hub-managed assets (optional) assetsSyncer *assets.Syncer