v0.91.1 — wire the credential probe (v0.91.0 shipped the seam inert)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user