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 }