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