package agentapi import ( "context" "errors" "net/http" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // 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 ) // String returns the state's wire/template vocabulary: "yes" | "no" | "unknown". func (s SupportState) String() string { switch s { case SupportYes: return "yes" case SupportNo: return "no" default: return "unknown" } } // 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 (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 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) return err }, } // 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 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. // // 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 { state, _ := sc.SupportsWithSource(ctx, p, f) return state } // SupportsWithSource is Supports plus the DECISION SOURCE ("version" | "probe-cache" | // "probe" | "unregistered") — the v0.116.0 observability extension so the gate line can // say HOW the verdict was reached. Behavior is byte-identical to v0.115.0 Supports. func (sc *SupportCache) SupportsWithSource(ctx context.Context, p SupportProber, f Feature) (SupportState, string) { probe, ok := featureProbes[f] if !ok { return SupportUnknown, "unregistered" // unregistered feature — never refuse on a table gap } if vr, hasVer := p.(AgentVersionReporter); hasVer { if state, decided := supportsByVersion(vr.AgentVersion(), f); decided { return state, "version" } } 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, "probe-cache" } 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, "probe" } // 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 { state, source := c.features.SupportsWithSource(ctx, c, f) logx.Debugf(c.logger, "[agentapi] Supports(%s) = %s (source=%s agent_version=%q)", f, state, source, c.AgentVersion()) return state } // 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. 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 }