v0.115.0: version-aware Supports (agent header channel) + DSM-validated NFS guidance — MinAgent: 0.81.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -17,7 +17,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,36 @@ type Client struct {
|
||||
hc *http.Client
|
||||
// features caches capability-probe verdicts for Supports (features.go).
|
||||
features SupportCache
|
||||
// verMu guards lastAgentVersion — the most recent STRICTLY-VALIDATED X-Felhom-Agent-Version
|
||||
// seen on any agent response (v0.82.0 version channel). "" = never seen (pre-0.82 agent) →
|
||||
// Supports falls back to the route probe.
|
||||
verMu sync.Mutex
|
||||
lastAgentVersion string
|
||||
}
|
||||
|
||||
// reAgentVersion is the bare-semver shape the publish pipeline enforces (publish-agent.sh) — the
|
||||
// ONLY header values trusted for capability comparison. Anything else (garbage, "dev", suffixes)
|
||||
// is ignored and the probe fallback stays in charge.
|
||||
var reAgentVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
|
||||
// noteAgentVersion records a response's version header (passive capture — called on EVERY response
|
||||
// path). Invalid/absent headers never overwrite a previously-seen valid version.
|
||||
func (c *Client) noteAgentVersion(resp *http.Response) {
|
||||
v := strings.TrimSpace(resp.Header.Get("X-Felhom-Agent-Version"))
|
||||
if v == "" || !reAgentVersion.MatchString(v) {
|
||||
return
|
||||
}
|
||||
c.verMu.Lock()
|
||||
c.lastAgentVersion = v
|
||||
c.verMu.Unlock()
|
||||
}
|
||||
|
||||
// AgentVersion returns the last strictly-validated agent version seen on this client's traffic
|
||||
// ("" = unknown — header-less agent or no traffic yet). This is the Supports comparison source.
|
||||
func (c *Client) AgentVersion() string {
|
||||
c.verMu.Lock()
|
||||
defer c.verMu.Unlock()
|
||||
return c.lastAgentVersion
|
||||
}
|
||||
|
||||
// MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6).
|
||||
@@ -491,6 +523,7 @@ func (c *Client) WipeStagedEscrowSecret(ctx context.Context) error {
|
||||
return fmt.Errorf("agentapi: DELETE /escrow/stage-secret: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var env apiResponse
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
@@ -771,6 +804,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
|
||||
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return env, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err)
|
||||
@@ -879,6 +913,7 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
|
||||
return nil, fmt.Errorf("agentapi: GET %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &StatusError{Path: path, Code: resp.StatusCode}
|
||||
@@ -911,6 +946,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
|
||||
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, fmt.Errorf("agentapi: POST %s: HTTP %d", path, resp.StatusCode)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Agent-capability probing (the publish-train backstop). A controller release that depends on
|
||||
@@ -53,13 +55,12 @@ type SupportProber interface {
|
||||
NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error)
|
||||
}
|
||||
|
||||
// featureProbes maps each coupled feature to its route probe.
|
||||
// featureProbes maps each coupled feature to its route probe (the FALLBACK path for agents that
|
||||
// predate the v0.82.0 version header).
|
||||
//
|
||||
// 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.
|
||||
// every future coupled feature adds a row here AND a featureMinAgent row, plus a Supports gate
|
||||
// call at its entry point, and declares MinAgent in its CHANGELOG header.
|
||||
var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error{
|
||||
FeatureNetstorageVerify: func(ctx context.Context, p SupportProber) error {
|
||||
_, err := p.NetVerifyStatus(ctx)
|
||||
@@ -67,6 +68,20 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
},
|
||||
}
|
||||
|
||||
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
|
||||
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
|
||||
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
|
||||
// feature missing here (or an unparseable table value) falls back to the probe.
|
||||
var featureMinAgent = map[Feature]string{
|
||||
FeatureNetstorageVerify: "0.81.0",
|
||||
}
|
||||
|
||||
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
|
||||
// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
|
||||
type AgentVersionReporter interface {
|
||||
AgentVersion() string
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -85,14 +100,24 @@ type SupportCache struct {
|
||||
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).
|
||||
// Supports reports whether the agent behind p provides feature f.
|
||||
//
|
||||
// Order (v0.115.0): (1) the agent's VERSION is known (the v0.82.0 header channel, strictly
|
||||
// validated at capture) AND the feature has a MinAgent row → pure semver comparison, NO probe
|
||||
// traffic, no cache involvement (the cache stays probe-only); (2) otherwise — pre-0.82 agent, no
|
||||
// traffic yet, or a table gap — the v0.114.0 probe path, byte-identical (cache inside the TTL
|
||||
// window, probe on miss). 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
|
||||
}
|
||||
if vr, hasVer := p.(AgentVersionReporter); hasVer {
|
||||
if state, decided := supportsByVersion(vr.AgentVersion(), f); decided {
|
||||
return state
|
||||
}
|
||||
}
|
||||
sc.mu.Lock()
|
||||
nowFn := sc.now
|
||||
if nowFn == nil {
|
||||
@@ -123,6 +148,31 @@ func (c *Client) Supports(ctx context.Context, f Feature) SupportState {
|
||||
return c.features.Supports(ctx, c, f)
|
||||
}
|
||||
|
||||
// supportsByVersion decides a feature by version comparison alone. decided=false (unknown/garbage
|
||||
// version, missing MinAgent row, unparseable table value) sends the caller to the probe fallback —
|
||||
// a bad version string must never be trusted in EITHER direction.
|
||||
func supportsByVersion(agentVer string, f Feature) (state SupportState, decided bool) {
|
||||
if agentVer == "" {
|
||||
return SupportUnknown, false
|
||||
}
|
||||
minStr, ok := featureMinAgent[f]
|
||||
if !ok {
|
||||
return SupportUnknown, false
|
||||
}
|
||||
av, err := util.ParseVersion(agentVer)
|
||||
if err != nil {
|
||||
return SupportUnknown, false // capture validates the shape, but stay defensive
|
||||
}
|
||||
mv, err := util.ParseVersion(minStr)
|
||||
if err != nil {
|
||||
return SupportUnknown, false // a broken table row falls back to probing, never refuses
|
||||
}
|
||||
if av.Compare(mv) >= 0 {
|
||||
return SupportYes, true
|
||||
}
|
||||
return SupportNo, true
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Version-aware Supports (v0.115.0, pairs with agent v0.82.0's X-Felhom-Agent-Version): a KNOWN
|
||||
// version decides by comparison with ZERO probe traffic; unknown/garbage stays on the v0.114.0
|
||||
// probe path byte-identically.
|
||||
|
||||
// verProber implements SupportProber + AgentVersionReporter with a probe counter.
|
||||
type verProber struct {
|
||||
ver string
|
||||
probes int
|
||||
}
|
||||
|
||||
func (p *verProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) {
|
||||
p.probes++
|
||||
return NetVerifyStatus{Phase: "none"}, nil // a live route (probe verdict would be Yes)
|
||||
}
|
||||
func (p *verProber) AgentVersion() string { return p.ver }
|
||||
|
||||
// --- B3.1: version known → compare path, probe count 0 -----------------------------------------
|
||||
// Companion red-proof: drop the supportsByVersion short-circuit in Supports → probes becomes 1.
|
||||
func TestSupports_VersionKnown_ComparesWithoutProbe(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ver string
|
||||
want SupportState
|
||||
}{
|
||||
{"0.82.0", SupportYes},
|
||||
{"0.81.0", SupportYes}, // boundary: MinAgent itself qualifies
|
||||
{"1.0.0", SupportYes}, // numeric compare across majors
|
||||
{"0.100.0", SupportYes}, // numeric, NOT lexicographic (0.100 > 0.81)
|
||||
{"0.79.0", SupportNo}, // below MinAgent → No, still without probing
|
||||
{"0.80.9", SupportNo},
|
||||
} {
|
||||
t.Run(tc.ver, func(t *testing.T) {
|
||||
p := &verProber{ver: tc.ver}
|
||||
var sc SupportCache
|
||||
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != tc.want {
|
||||
t.Errorf("Supports(ver=%s) = %v, want %v", tc.ver, got, tc.want)
|
||||
}
|
||||
if p.probes != 0 {
|
||||
t.Errorf("version-known path must NOT probe (ver=%s, probes=%d)", tc.ver, p.probes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- B3.2: garbage/absent version → the probe fallback, byte-identical --------------------------
|
||||
// Companion red-proof: trust the unvalidated header text (compare without ParseVersion error
|
||||
// handling) → the garbage rows would refuse or panic instead of probing.
|
||||
func TestSupports_GarbageOrNoVersion_ProbeFallback(t *testing.T) {
|
||||
for _, ver := range []string{"", "dev", "0.82", "0.82.0-rc1", "v0.82.0-beta", "evil;rm -rf", "9999999999999999999999.0.0"} {
|
||||
t.Run("ver="+ver, func(t *testing.T) {
|
||||
p := &verProber{ver: ver}
|
||||
var sc SupportCache
|
||||
got := sc.Supports(context.Background(), p, FeatureNetstorageVerify)
|
||||
if p.probes != 1 {
|
||||
t.Fatalf("unusable version %q must fall back to EXACTLY one probe, got %d", ver, p.probes)
|
||||
}
|
||||
if got != SupportYes { // the fake's route answers → the probe decides Yes
|
||||
t.Errorf("probe fallback verdict = %v, want SupportYes", got)
|
||||
}
|
||||
// Second call: the probe verdict is cached (the v0.114.0 behavior, unchanged).
|
||||
_ = sc.Supports(context.Background(), p, FeatureNetstorageVerify)
|
||||
if p.probes != 1 {
|
||||
t.Errorf("cached probe verdict must not re-probe (probes=%d)", p.probes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A prober that does NOT implement AgentVersionReporter (the web fakes' shape) keeps the pure
|
||||
// v0.114.0 behavior — the interface assertion must not change anything for it.
|
||||
func TestSupports_NonReporterProber_Unchanged(t *testing.T) {
|
||||
p := &fakeProber{} // the existing v0.114.0 test fake (features_test.go)
|
||||
var sc SupportCache
|
||||
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes {
|
||||
t.Fatalf("non-reporter prober = %v, want SupportYes via probe", got)
|
||||
}
|
||||
if p.calls != 1 {
|
||||
t.Errorf("non-reporter prober must probe exactly once, got %d", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- B3.4: the ONE comparator, table-driven (incl. pre-release suffixes) ------------------------
|
||||
func TestVersionComparator_Table(t *testing.T) {
|
||||
lt := func(a, b string) {
|
||||
t.Helper()
|
||||
av, err1 := util.ParseVersion(a)
|
||||
bv, err2 := util.ParseVersion(b)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("parse %q/%q: %v %v", a, b, err1, err2)
|
||||
}
|
||||
if av.Compare(bv) != -1 || bv.Compare(av) != 1 {
|
||||
t.Errorf("want %s < %s", a, b)
|
||||
}
|
||||
}
|
||||
lt("0.81.0", "0.82.0")
|
||||
lt("0.81.0", "0.100.0") // numeric minor, not lexicographic
|
||||
lt("0.99.9", "1.0.0")
|
||||
lt("1.2.3", "1.2.10")
|
||||
if v, err := util.ParseVersion("v0.82.0"); err != nil || v.Raw != "0.82.0" {
|
||||
t.Errorf("v-prefix must parse: %v %v", v, err)
|
||||
}
|
||||
if eq, _ := util.ParseVersion("0.81.0"); eq.Compare(eq) != 0 {
|
||||
t.Error("equal versions must compare 0")
|
||||
}
|
||||
// Pre-release suffixes are REJECTED by the house comparator — in Supports they mean "fall back
|
||||
// to the probe", never a trusted comparison.
|
||||
for _, bad := range []string{"1.2.3-rc1", "dev", "latest", "", "1.2", "a.b.c"} {
|
||||
if _, err := util.ParseVersion(bad); err == nil {
|
||||
t.Errorf("ParseVersion(%q) must error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B3.5: wire-level — the header decides over the probe through the REAL pinned client --------
|
||||
// A ROUTELESS agent (probe would say No) that sends X-Felhom-Agent-Version 9.9.9 must be judged by
|
||||
// the VERSION (Yes): proof the channel takes precedence end-to-end, and that capture happens on an
|
||||
// ordinary (even failing) call.
|
||||
func TestClient_VersionHeaderWins_WireLevel(t *testing.T) {
|
||||
mux := http.NewServeMux() // NO /netstorage/verify-status (the pre-0.81 route shape)
|
||||
mux.HandleFunc("/storage", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Felhom-Agent-Version", "9.9.9")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":1,"mounts":[]}}`))
|
||||
})
|
||||
wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Felhom-Agent-Version", "9.9.9") // every response, like the agent's wrap
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
srv := httptest.NewTLSServer(wrapped)
|
||||
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 _, err := c.Storage(context.Background()); err != nil {
|
||||
t.Fatalf("storage: %v", err)
|
||||
}
|
||||
if got := c.AgentVersion(); got != "9.9.9" {
|
||||
t.Fatalf("AgentVersion = %q, want 9.9.9 (passive capture)", got)
|
||||
}
|
||||
if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportYes {
|
||||
t.Errorf("Supports = %v, want SupportYes via version despite the missing probe route", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A GARBAGE header must never be captured: the routeless agent stays on the probe → SupportNo.
|
||||
func TestClient_GarbageHeaderIgnored_WireLevel(t *testing.T) {
|
||||
wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Felhom-Agent-Version", "not-a-version;x")
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
srv := httptest.NewTLSServer(wrapped)
|
||||
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)
|
||||
}
|
||||
_, _ = c.Storage(context.Background()) // 404s; the garbage header must be dropped at capture
|
||||
if got := c.AgentVersion(); got != "" {
|
||||
t.Fatalf("AgentVersion = %q, want empty (strict validation at capture)", got)
|
||||
}
|
||||
if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportNo {
|
||||
t.Errorf("Supports = %v, want SupportNo via the probe fallback", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user