feat: agent-capability gate for coupled features — typed StatusError + Supports probe/cache + netstorage add gate (option-1)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 13:45:49 +02:00
parent 5d06ecf374
commit d347dc48d2
6 changed files with 511 additions and 1 deletions
@@ -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)
}
})
}
}
@@ -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
+3
View File
@@ -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