From dcf3a5530b555a50dbed161c3b9903dcb7b5cfea Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 21 Jul 2026 10:18:41 +0200 Subject: [PATCH] =?UTF-8?q?v0.91.1=20=E2=80=94=20wire=20the=20credential?= =?UTF-8?q?=20probe=20(v0.91.0=20shipped=20the=20seam=20inert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.91.0 built the AuthSink seam and the NoteAuthResult consumer, and main.go never called SetAuthSink. The reporter skips probing when no sink is attached, so the entire auth-honesty leg was silently inert — no probe, no auth_failed, no self-heal — and nothing failed, because every unit test injected the sink directly. Caught during STOP-1 live verification by checking the wiring instead of trusting it. Same class as the controller v0.154.0 defect the day before: a table test over a seam proves the seam, not the caller. The published 0.91.0 artifact is superseded, not overwritten — a published version stays immutable. TestLiveReporter_NoSinkMeansNoProbe pins the no-sink-no-probe contract so the inert case is documented behaviour rather than an accident; only live evidence can prove the wiring itself. --- CHANGELOG.md | 19 ++++++ cmd/felhom-agent/main.go | 5 ++ internal/pbs/authprobe_test.go | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 internal/pbs/authprobe_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 319e850..8d8a64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v0.91.1 — wire the credential probe (v0.91.0 shipped the seam inert) (2026-07-21) + +**Supersedes v0.91.0; that artifact is materially incomplete — do not vouch it.** + +v0.91.0 built the `pbs.AuthSink` seam and the `NoteAuthResult` consumer, and `main.go` never called +`SetAuthSink`. The reporter deliberately skips probing when no sink is attached, so the whole +auth-honesty leg was silently inert: no probe, no `auth_failed`, no self-heal — and nothing failed, +because every unit test injected the sink directly. + +Caught during STOP-1 live verification by checking the wiring rather than trusting it. **Exactly the +same class as the controller v0.154.0 defect the day before: a table test over a seam proves the +seam, not the caller.** The published 0.91.0 artifact is left in place and superseded rather than +overwritten — a published version must stay immutable. + +- `main.go`: `pbsReporter.SetAuthSink(pdMgr)` in the pbsdr bridge block. +- `TestLiveReporter_NoSinkMeansNoProbe` pins the no-sink-no-probe contract, so the inert case stays a + documented behaviour rather than an accident nobody notices twice. The wiring itself is proven by + the live STOP-1/STOP-2 evidence, which is the only thing that can prove it. + ## v0.91.0 — the DR tier can no longer be `applied` and dead at the same time (R-39 fleet fix + R-50b(a)) (2026-07-21) **Requires hub >= v0.68.0** for the re-arm signal. Hub v0.68.0 is safe for 0.90.0 agents (they drop diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 04063aa..b66913d 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -864,6 +864,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int pbsdrLoop = pbsdr.NewLoop(pdMgr, 60*time.Second, logger) desiredSyncer.AddConsumer(pbsdrLoop) // raw desired-state → the pbs_dr block collector.SetPBSDRReporter(pbsdrLoop) + // R-39 leg (c): route the live reporter's per-storage credential probe into the DR bridge, so + // a 401 becomes a LOUD `auth_failed` the hub self-heals instead of a Warn-and-skip. Without + // this wiring the probe seam exists but nothing consumes it — and the reporter deliberately + // skips probing when no sink is attached, so the whole leg would be silently inert. + pbsReporter.SetAuthSink(pdMgr) // Capability gate wiring (v0.86.0): the prober's GatePBSDR now answers from the bridge // (descriptor state, marker-backed across restarts) — see the capProber block above. drConfigured = pdMgr.DRConfigured diff --git a/internal/pbs/authprobe_test.go b/internal/pbs/authprobe_test.go new file mode 100644 index 0000000..8f22e28 --- /dev/null +++ b/internal/pbs/authprobe_test.go @@ -0,0 +1,109 @@ +package pbs + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/hub" +) + +type recordingSink struct { + calls []struct { + storage string + unauthorized bool + detail string + } +} + +func (s *recordingSink) NoteAuthResult(storageID string, unauthorized bool, detail string) { + s.calls = append(s.calls, struct { + storage string + unauthorized bool + detail string + }{storageID, unauthorized, detail}) +} + +func newProbeReporter(t *testing.T, sink AuthSink, probeErr error) *LiveSnapshotReporter { + t.Helper() + targets := func(ctx context.Context) ([]Target, error) { + return []Target{{Datastore: "felhom-offsite", StorageID: "felhom-pbs"}}, nil + } + r := NewLiveSnapshotReporter(targets, NewSnapshotStore(), 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + r.listSnapshots = func(context.Context, Target) ([]hub.PBSSnapshot, error) { return nil, nil } + r.probeAuth = func(context.Context, Target) error { return probeErr } + if sink != nil { + r.SetAuthSink(sink) + } + return r +} + +// R-39 leg (c): a 401 must reach the sink as UNAUTHORIZED — the signal the DR bridge turns into a +// loud auth_failed state. +func TestLiveReporter_ForwardsUnauthorized(t *testing.T) { + sink := &recordingSink{} + r := newProbeReporter(t, sink, ErrUnauthorized) + r.PBSSnapshots(context.Background()) + + if len(sink.calls) != 1 { + t.Fatalf("sink calls = %d, want 1 (the probe must run on every collect)", len(sink.calls)) + } + c := sink.calls[0] + if !c.unauthorized || c.storage != "felhom-pbs" { + t.Errorf("got %+v, want unauthorized for felhom-pbs", c) + } +} + +// A TRANSPORT error is UNKNOWN, never a rejection: reporting it as unauthorized would re-key a +// perfectly good credential on every network blip. +func TestLiveReporter_TransportErrorIsNotUnauthorized(t *testing.T) { + sink := &recordingSink{} + r := newProbeReporter(t, sink, errors.New("dial tcp 10.77.0.1:8007: connect: connection refused")) + r.PBSSnapshots(context.Background()) + + if len(sink.calls) != 1 { + t.Fatalf("sink calls = %d, want 1", len(sink.calls)) + } + if sink.calls[0].unauthorized { + t.Error("an unreachable PBS was reported as unauthorized — every blip would burn a credential") + } + if sink.calls[0].detail == "" { + t.Error("an inconclusive probe must carry a detail so the state is explainable") + } +} + +// A healthy credential clears: unauthorized=false with an EMPTY detail is the recovery signal the +// bridge keys on. +func TestLiveReporter_HealthyProbeIsACleanClear(t *testing.T) { + sink := &recordingSink{} + r := newProbeReporter(t, sink, nil) + r.PBSSnapshots(context.Background()) + + if len(sink.calls) != 1 || sink.calls[0].unauthorized || sink.calls[0].detail != "" { + t.Fatalf("got %+v, want a clean clear (unauthorized=false, detail=\"\")", sink.calls) + } +} + +// THE WIRING GUARD. With no sink attached the reporter must not probe at all — and this test exists +// because that is exactly how the leg shipped inert the first time: the seam was built and main.go +// never called SetAuthSink, so probeAuth was never invoked and nothing failed. +// +// A unit test cannot assert main.go's wiring; the live STOP-1/STOP-2 evidence does that. What it CAN +// pin is the contract this depends on — no sink means no probe — so the inert case stays a +// deliberate, documented behaviour rather than an accident nobody notices twice. +func TestLiveReporter_NoSinkMeansNoProbe(t *testing.T) { + probed := false + targets := func(ctx context.Context) ([]Target, error) { + return []Target{{Datastore: "d", StorageID: "s"}}, nil + } + r := NewLiveSnapshotReporter(targets, NewSnapshotStore(), 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + r.listSnapshots = func(context.Context, Target) ([]hub.PBSSnapshot, error) { return nil, nil } + r.probeAuth = func(context.Context, Target) error { probed = true; return nil } + // deliberately no SetAuthSink + r.PBSSnapshots(context.Background()) + if probed { + t.Error("probed with no sink attached — a request nothing consumes") + } +}