Compare commits
4 Commits
5d06ecf374
...
d9da5a0678
| Author | SHA1 | Date | |
|---|---|---|---|
| d9da5a0678 | |||
| 4a5c40b7cb | |||
| 50da7329ac | |||
| d347dc48d2 |
@@ -1,5 +1,39 @@
|
||||
## Changelog
|
||||
|
||||
### v0.114.0 — agent-capability gate for coupled features (2026-07-11) — MinAgent: —
|
||||
|
||||
Box-level backstop for the publish-train ordering discipline (incident: the 0.81/0.113 train's
|
||||
9-minute controller-before-agent skew on Peti's box — RUNBOOK-publish-0.81-0.113-2026-07-11): the
|
||||
controller now detects whether its agent supports a coupled feature and refuses that feature up
|
||||
front, instead of failing mid-pipeline with a misleading rollback. No agent or hub changes; works
|
||||
against agents 0.79–0.81 as they exist. (Retroactive note: v0.113.0's effective MinAgent was
|
||||
0.81.0 for the NAS add — this release is the machinery that makes such coupling self-protecting.
|
||||
Header convention from here on: coupled releases declare `MinAgent: X.Y.Z` on this line.)
|
||||
|
||||
- **agentapi typed status (1.1):** non-2xx GETs surface as typed `*StatusError{Path,Code}` (same
|
||||
message text as the old formatted error) — the probe keys on `Code==404` via `errors.As`, never
|
||||
string matching.
|
||||
- **`internal/agentapi/features.go`:** `Feature`/`SupportState` + `featureProbes` table (one row:
|
||||
`netstorage_verify` → `GET /netstorage/verify-status`, the route that shipped WITH the coupled
|
||||
add semantics in agent v0.81.0) + `SupportCache` (TTL 5 min, Yes/No cached, Unknown NEVER cached
|
||||
or refused) + `Client.Supports`. 2xx ⇒ Yes; 404 ⇒ No; transport/timeout/401/5xx ⇒ Unknown — an
|
||||
agent problem is never claimed as "too old".
|
||||
- **Add gate:** `handleNetStorageAdd` refuses on `SupportNo` BEFORE the single-flight claim —
|
||||
HTTP 412, machine code `agent_outdated`, message "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." `SupportUnknown` passes through to the
|
||||
existing agent-error paths. `remove`/`list`/health are NOT gated — old shares stay manageable.
|
||||
- **Settings page:** `NetAddSupport` (yes/no/unknown, short 2 s probe budget + cache) — `no` swaps
|
||||
the add form for the honest banner; the share list + remove render in every state.
|
||||
- **Tests:** T1 gate refusal (job never starts, slot never claimed, zero agent calls), T2 unchanged
|
||||
happy path + warm-cache NEGATIVE assertion (probe count stays 1 across two adds), T3
|
||||
indeterminate-never-refuses, T4 classification incl. the string-match trap case, T5 banner
|
||||
render, T6 TTL re-fire, wire-level 404-typing through the pinned client. Red-proofs RP1–RP5 run +
|
||||
reverted (recorded in REPORT.md).
|
||||
- Also: fixed a scheduling flake in `TestBackupTier2Restore_DoubleClickRefused` (pre-existing).
|
||||
- **Docs:** publish-train rules codified at `felhom.eu/documentation/runbooks/publish-train-rules.md`
|
||||
(manifest-before-floor; floor field LAST — the DB row overrides env and acts immediately;
|
||||
MinAgent fleet gate; this gate as backstop).
|
||||
|
||||
### v0.113.0 — NAS verify-before-commit + page redesign + protocol-honest guidance (2026-07-11)
|
||||
|
||||
Kills the "bogus share sits at Készenlét forever" bug: `POST /api/storage/netstorage/add` now
|
||||
|
||||
@@ -88,6 +88,10 @@ Per-package helpers/seams/traps: **`REUSE.md`** (maintained same-commit as helpe
|
||||
skill; templates must pass `controller/scripts/template_id_gate.py` + `emoji_gate.py`.
|
||||
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
|
||||
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
|
||||
- **Coupled features** (controller behavior that depends on a specific agent version): add a
|
||||
`featureProbes` table row in `internal/agentapi/features.go` + a `Supports` gate call at the
|
||||
feature's entry point; declare `MinAgent: X.Y.Z` in the CHANGELOG entry header. Rules:
|
||||
`felhom.eu/documentation/runbooks/publish-train-rules.md`.
|
||||
|
||||
> **In every repository where you make a change, update both files in that repo:**
|
||||
> - **`CHANGELOG.md`** — cumulative log, newest on top.
|
||||
|
||||
+17
-1
@@ -7,7 +7,23 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-11 (v0.113.0 — NAS verify-before-commit + page redesign; pairs with agent v0.81.0)
|
||||
Last updated: 2026-07-11 (v0.114.0 — agent-capability gate for coupled features)
|
||||
|
||||
> **2026-07-11 — v0.114.0: agent-capability gate (option-1) + publish-train rules (option-2).**
|
||||
> Answer to the 0.81/0.113 train's 9-minute controller-before-agent skew (Peti's box): the box now
|
||||
> protects itself. `internal/agentapi/features.go` — `Supports(Feature)` route-probes the agent
|
||||
> (`GET /netstorage/verify-status` = the v0.81.0 coupling signal; typed `StatusError` 404 ⇒ No, 2xx
|
||||
> ⇒ Yes, transport/5xx ⇒ Unknown NEVER refused; `SupportCache` TTL 5m both polarities, Unknown
|
||||
> uncached). `handleNetStorageAdd` refuses on No BEFORE the single-flight claim (412 +
|
||||
> `agent_outdated` + honest Hungarian message); settings page swaps the add form for a banner
|
||||
> (list/remove untouched in every state). remove/list NOT gated. NO agent/hub changes; NOT
|
||||
> published, floor untouched, Peti stays 0.113.0 — the gate is inert protection until the next
|
||||
> train. Rules codified: `felhom.eu/documentation/runbooks/publish-train-rules.md` (manifest before
|
||||
> floor; floor field LAST — hub_settings DB row overrides env + acts immediately; MinAgent fleet
|
||||
> gate — CHANGELOG header convention starts with this release; the gate as box-level backstop).
|
||||
> Tests T1–T6 + wire-level 404-typing; red-proofs RP1–RP5 in REPORT.md. Roadmap: agent
|
||||
> version-in-envelope upgrade of `Supports`; hub floor-UI separation = its own task. The
|
||||
> `agent_outdated` branch is test-proven only (demo agent is current — downgrade not justified).
|
||||
|
||||
> **2026-07-11 — v0.113.0: NAS verify-before-commit + page redesign (LIVE on 9201, pairs with agent
|
||||
> v0.81.0 + host-install v1.13.0).** `POST /api/storage/netstorage/add` no longer registers blind
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only). Agent re-checks role server-side |
|
||||
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
|
||||
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
||||
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
||||
| `SupportCache.Supports` / `Client.Supports` | controller/internal/agentapi/features.go | `(ctx, prober, Feature) SupportState` | Agent-capability gate for COUPLED features (route probe, TTL 5m) | 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new `featureProbes` row + gate call at the entry point + `MinAgent:` in the CHANGELOG header (publish-train-rules.md). Web layer: `Server.netFeatures` through the `netAgent` seam |
|
||||
|
||||
### Notifications / hub sync
|
||||
|
||||
|
||||
@@ -813,6 +813,17 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
|
||||
> (`storage_network.html`) is on the canonical form pattern with staged poll progress and the
|
||||
> protocol-honest NAS guidance (SMB-first; NFS map-all-users vs full-fidelity `anonuid=<uid+100000>`).
|
||||
> Authoritative doc: `felhom.eu/documentation/controller/network-storage-nas.md`.
|
||||
> - **Agent-capability gate (v0.114.0):** the verify-before-commit add is COUPLED to agent ≥ v0.81.0,
|
||||
> so the add entry point now probes the agent first (`internal/agentapi/features.go`,
|
||||
> `Supports(FeatureNetstorageVerify)` — a route probe on `GET /netstorage/verify-status`: 2xx ⇒
|
||||
> supported, typed 404 ⇒ older agent, transport/5xx ⇒ indeterminate, cached 5 min both polarities).
|
||||
> On an older agent the add is refused SYNCHRONOUSLY (HTTP 412, machine code `agent_outdated`,
|
||||
> honest Hungarian message) BEFORE the single-flight claim — never a misleading mid-pipeline
|
||||
> rollback; indeterminate NEVER refuses (a down agent speaks through the existing error paths). The
|
||||
> settings page swaps the add form for a banner on `SupportNo` (share list + remove stay usable in
|
||||
> every state). Convention: every future coupled feature adds a `featureProbes` row + a gate call at
|
||||
> its entry point and declares `MinAgent` in its CHANGELOG header — see
|
||||
> `felhom.eu/documentation/runbooks/publish-train-rules.md`.
|
||||
> - **Limits (v1):** a share's `+100000` uid mapping is fixed at add-time (one app / same-uid apps); for
|
||||
> write apps on a soft NFS mount, an in-flight file can truncate if the NAS vanishes mid-write (prefer
|
||||
> atomic-write apps / SSD-staging).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,9 @@ func TestBackupTier2Restore_DoubleClickRefused(t *testing.T) {
|
||||
if loc := w2.Header().Get("Location"); !strings.Contains(loc, "m%C3%A1r+fut") && !strings.Contains(loc, "már fut") {
|
||||
t.Fatalf("second POST should be refused with 'már fut'; got %q", loc)
|
||||
}
|
||||
// IsRunning flips before the goroutine reaches StopStack — wait for the FIRST stop to land, so
|
||||
// the ==1 assertion below measures "no second launch", not goroutine scheduling (parallel-load flake).
|
||||
waitFor(t, func() bool { return atomic.LoadInt32(&prov.stops) >= 1 }, "first restore reached StopStack")
|
||||
if got := atomic.LoadInt32(&prov.stops); got != 1 {
|
||||
t.Fatalf("double-click launched a second restore: StopStack count = %d, want 1", got)
|
||||
}
|
||||
|
||||
@@ -1048,6 +1048,9 @@ func (s *Server) storagePageData() map[string]interface{} {
|
||||
func (s *Server) networkStoragePageData() map[string]interface{} {
|
||||
data := s.settingsBaseData("storage-network", "Hálózati tárhely")
|
||||
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
|
||||
// Capability banner: "no" swaps the add form for the agent-outdated notice (yes/unknown render
|
||||
// the form — flaky states belong to the add-time gate). Short-budget probe, cache-backed.
|
||||
data["NetAddSupport"] = s.netAddSupport()
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
|
||||
s.loadTemplates()
|
||||
data := map[string]interface{}{
|
||||
"Page": "storage-network", "Title": "Hálózati tárhely",
|
||||
"NetAddSupport": "yes",
|
||||
"NetworkStoragePaths": []networkStorageItem{
|
||||
{Name: "media", Label: "NAS media", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/media", Path: "/mnt/felhom-drives/media", Health: "ok"},
|
||||
{Name: "ghost", Label: "Árva megosztás: ghost", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/ghost", Path: "/mnt/felhom-drives/ghost", Health: "idle", Orphan: true},
|
||||
@@ -54,3 +55,52 @@ func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
|
||||
t.Error("remove action missing")
|
||||
}
|
||||
}
|
||||
|
||||
// --- T5 (Scenario D): capability banner replaces the add form on an old agent -------------------
|
||||
// Companion red-proof: drop the {{if eq .NetAddSupport "no"}} wrapper (always render the form) →
|
||||
// the banner-present + form-absent assertions on "no" fail.
|
||||
func TestStorageNetworkTemplate_AgentOutdatedBanner(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.loadTemplates()
|
||||
render := func(support string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
err := s.tmpl.ExecuteTemplate(&buf, "storage_network", map[string]interface{}{
|
||||
"Page": "storage-network", "Title": "Hálózati tárhely",
|
||||
"NetAddSupport": support,
|
||||
"NetworkStoragePaths": []networkStorageItem{
|
||||
{Name: "media", Label: "NAS media", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/media", Path: "/mnt/felhom-drives/media", Health: "ok"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render (support=%s): %v", support, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
const banner = "A rendszer háttérszolgáltatása (ügynök) régebbi verziójú"
|
||||
t.Run("no → banner instead of form; list + remove intact", func(t *testing.T) {
|
||||
html := render("no")
|
||||
if !strings.Contains(html, banner) {
|
||||
t.Error("the §Part-3 banner is missing on an old agent")
|
||||
}
|
||||
if strings.Contains(html, `id="ns-add-form"`) {
|
||||
t.Error("the add form must NOT render on an old agent")
|
||||
}
|
||||
// The share list + remove action stay fully usable in every state.
|
||||
if !strings.Contains(html, "NAS media") || !strings.Contains(html, "Eltávolítás") {
|
||||
t.Error("share list/remove must still render under the banner")
|
||||
}
|
||||
})
|
||||
for _, support := range []string{"yes", "unknown"} {
|
||||
t.Run(support+" → form as today", func(t *testing.T) {
|
||||
html := render(support)
|
||||
if !strings.Contains(html, `id="ns-add-form"`) {
|
||||
t.Errorf("the add form must render on support=%s", support)
|
||||
}
|
||||
if strings.Contains(html, banner) {
|
||||
t.Errorf("the banner must NOT render on support=%s", support)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Hálózati tárhely hozzáadása</h3>
|
||||
{{if eq .NetAddSupport "no"}}
|
||||
<!-- Capability gate (agent predates the verify-before-commit add semantics): the add form is
|
||||
withheld; the share list + remove above stay fully usable. -->
|
||||
<div class="alert alert-warning">A rendszer háttérszolgáltatása (ügynök) régebbi verziójú, ezért új
|
||||
hálózati tárhely most nem csatlakoztatható. A frissítés megérkezése után a funkció automatikusan
|
||||
elérhetővé válik — a meglévő megosztások addig is működnek.</div>
|
||||
{{else}}
|
||||
<p class="settings-card-desc">A csatlakoztatás előtt a rendszer ellenőrzi a megosztást: valóban
|
||||
csatolható-e, és tudnak-e írni rá az alkalmazások. Hiba esetén semmi nem marad félkészen beállítva.</p>
|
||||
<form id="ns-add-form" onsubmit="return netStorageAdd(event)">
|
||||
@@ -93,6 +100,7 @@
|
||||
</div>
|
||||
<div id="ns-add-result" style="margin-top:1rem"></div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
@@ -206,8 +214,7 @@ function netStorageRemove(name,label){
|
||||
}).catch(function(e){ alert('Hiba: '+e); });
|
||||
}});
|
||||
}
|
||||
nsToggleSmb();
|
||||
nsUpdateHostID();
|
||||
if(document.getElementById('ns-add-form')){ nsToggleSmb(); nsUpdateHostID(); } // form absent on the capability banner
|
||||
</script>
|
||||
<div id="dialog-root"></div>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user