Files
felhom.eu/hub/internal/pbsdrheal/reconciler_test.go
T
admin 107f74ea3c hub v0.68.0 — auth_failed self-heal, consumed_at honesty gauge, wrapper drift (R-39 + R-50b(a))
Completes the hub half of R-39's fleet fix on top of the generation core (c484aa2).

pbsdrheal gains an auth_failed TRIGGER — a new trigger in the existing machine, not a
new machine. A box whose credential PBS rejects escalates to a fresh mint, never a
re-stage (which would re-feed the secret PBS just rejected), through the EXISTING damper:
a 401 flap must not become a secret-minting chain. With the generation stamp this closes
the loop end to end — agent proves the 401, hub re-keys, generation advances, descriptor
hash moves, agent re-consumes.

consumed_at honesty gauge: a staged secret still unconsumed past a 15-minute grace while
the box reports `applied` is surfaced with its own event. That is the exact 2026-07-18
fingerprint and a disagreement no single tier can see alone. Deliberately a SURFACE, not
a heal — auto-re-issuing on it would mint a second secret on top of an unconsumed one,
which is the mint/consume race R-39(a) already recorded. One event per distinct report,
and an honestly-stuck box does not double-report (its unconsumed secret is the symptom
being healed, not a contradiction).

R-50b(a): ArtifactManifest.WrapperSHA256 + operator field + host-page drift surface. The
PBS wrapper is root-owned 0755 and the pinned sudoers vector, yet installed unversioned
from raw/branch/main and absent from every manifest. Agents >=0.91.0 report the installed
hash; a mismatch is surfaced. An unknown on EITHER side reads as quiet, never as drift —
lighting every host amber on rollout day is how a warning becomes background noise. The
delivery channel itself stays R-50b(b)/(c).

Compatibility unchanged: safe for 0.90.0 agents (unknown JSON key dropped); the re-arm
and auth-honesty guarantees need agent >=0.91.0, so MinAgent moves only after the fleet
has self-updated.

Tests: auth_failed escalate/debounce/recovery-forgets-streak; honesty gauge incl. grace
window, the restage edge (consumed_at deliberately NULLed), consumed-never-alarms, and
honest-stuck-no-double-report; wrapper drift incl. both unknown directions. Red-proof run
at the assertion level: removing the auth_failed arm fails the escalation tests with
reissues=0.
2026-07-21 10:01:35 +02:00

475 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pbsdrheal
// Non-hollow reconciler tests mapping 1:1 to the TASK's integration scenarios AF. A REAL store
// (t.TempDir sqlite) supplies the work-set + reports; a FAKE Actions seam counts restage/reissue
// calls without any SSH/ep0. reconcileOnce is driven directly (same package) for determinism. Audit
// events are asserted against the real store. Each test is the durable guard for its §10 red-proof.
import (
"context"
"io"
"log"
"path/filepath"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
type fakeActions struct {
mu sync.Mutex
restageHosts []string
reissueCusts []string
restageResult bool // what Restage returns (does a stored row exist?)
restageErr error
reissueErr error
}
func (f *fakeActions) Restage(hostID string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.restageHosts = append(f.restageHosts, hostID)
return f.restageResult, f.restageErr
}
func (f *fakeActions) Reissue(ctx context.Context, customerID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.reissueCusts = append(f.reissueCusts, customerID)
return f.reissueErr
}
func (f *fakeActions) restages() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.restageHosts) }
func (f *fakeActions) reissues() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.reissueCusts) }
func newHealStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
// seedHost writes a host with a pbs_dr descriptor of the requested enable/provision shape.
func seedHost(t *testing.T, st *store.Store, hostID, customerID string, enabled, provisioned bool) {
t.Helper()
if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "k-" + hostID}); err != nil {
t.Fatalf("UpsertHost %s: %v", hostID, err)
}
ns := ""
if provisioned {
ns = customerID
}
desc := `{"pbs_dr":{"enabled":` + boolStr(enabled) + `,"namespace":"` + ns + `","storage_id":"felhom-pbs"}}`
if _, err := st.SetHostDesired(hostID, []byte(desc)); err != nil {
t.Fatalf("SetHostDesired %s: %v", hostID, err)
}
}
func seedReport(t *testing.T, st *store.Store, hostID, customerID, state string) {
t.Helper()
if err := st.SaveHostReport(hostID, customerID, []byte(`{"pbs_dr":{"state":"`+state+`"}}`), store.HostReportDenorm{AgentVersion: "0.88.0"}); err != nil {
t.Fatalf("SaveHostReport %s: %v", hostID, err)
}
}
// ageSecret back-dates a staged secret's created_at so grace-window behaviour is testable without
// sleeping. Writes the house SQLite datetime format.
func ageSecret(t *testing.T, st *store.Store, hostID string, by time.Duration) {
t.Helper()
when := time.Now().UTC().Add(-by).Format("2006-01-02 15:04:05")
if err := st.SetHostPBSSecretCreatedAtForTest(hostID, when); err != nil {
t.Fatalf("age secret %s: %v", hostID, err)
}
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func newRec(st *store.Store, act Actions) *Reconciler {
r := NewReconciler(st, act, log.New(io.Discard, "", 0))
r.debounceReports = 1 // fire on the first stuck report unless a test overrides
return r
}
func genOf(t *testing.T, st *store.Store, hostID string) int64 {
t.Helper()
h, err := st.GetHost(hostID)
if err != nil || h == nil {
t.Fatalf("GetHost %s: (%v, %v)", hostID, h, err)
}
return h.DesiredGeneration
}
func eventTypes(t *testing.T, st *store.Store, customerID string) []string {
t.Helper()
evs, err := st.GetRecentEvents(customerID, 50)
if err != nil {
t.Fatalf("GetRecentEvents: %v", err)
}
var out []string
for _, e := range evs {
out = append(out, e.EventType)
}
return out
}
// A — the headline: a re-installed box (descriptor enabled+provisioned, stored CONSUMED secret,
// waiting_secret) is re-staged — NOT reissued, and NO generation bump.
func TestScenarioA_WaitingSecretRestages(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hA", "cA", true, true)
seedReport(t, st, "hA", "cA", stateWaitingSecret)
genBefore := genOf(t, st, "hA")
fake := &fakeActions{restageResult: true} // a stored secret exists
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 1 || fake.reissues() != 0 {
t.Fatalf("restages=%d reissues=%d, want 1/0 (re-stage, not re-issue)", fake.restages(), fake.reissues())
}
if fake.restageHosts[0] != "hA" {
t.Errorf("restaged host = %s, want hA", fake.restageHosts[0])
}
if g := genOf(t, st, "hA"); g != genBefore {
t.Errorf("generation bumped by a re-stage heal: %d -> %d", genBefore, g)
}
if got := eventTypes(t, st, "cA"); len(got) != 1 || got[0] != eventRestaged {
t.Errorf("events = %v, want [%s]", got, eventRestaged)
}
}
// B — no stored secret → escalate to Re-issue exactly once.
func TestScenarioB_NoStoredSecretReissues(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hB", "cB", true, true)
seedReport(t, st, "hB", "cB", stateWaitingSecret)
fake := &fakeActions{restageResult: false} // no row to re-stage
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 1 || fake.reissues() != 1 {
t.Fatalf("restages=%d reissues=%d, want 1/1 (tried re-stage, then escalated)", fake.restages(), fake.reissues())
}
if fake.reissueCusts[0] != "cB" {
t.Errorf("reissued customer = %s, want cB", fake.reissueCusts[0])
}
if got := eventTypes(t, st, "cB"); len(got) != 1 || got[0] != eventReissued {
t.Errorf("events = %v, want [%s]", got, eventReissued)
}
}
// C — a converged/healthy host is a pure no-op (idempotency; the gen-thrash guard).
func TestScenarioC_ConvergedHostNoOp(t *testing.T) {
for _, state := range []string{"applied", "adopted", "disabled", "verify_failed"} {
t.Run(state, func(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hC", "cC", true, true)
seedReport(t, st, "hC", "cC", state)
genBefore := genOf(t, st, "hC")
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 0 || fake.reissues() != 0 {
t.Fatalf("state %s: restages=%d reissues=%d, want 0/0 (no-op)", state, fake.restages(), fake.reissues())
}
if g := genOf(t, st, "hC"); g != genBefore {
t.Errorf("state %s: generation changed on a healthy host", state)
}
if got := eventTypes(t, st, "cC"); len(got) != 0 {
t.Errorf("state %s: events = %v, want none", state, got)
}
})
}
}
// D — debounce: a single waiting_secret report does NOT heal; a second DISTINCT one does.
func TestScenarioD_DebounceRequiresTwoDistinctReports(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hD", "cD", true, true)
fake := &fakeActions{restageResult: true}
r := NewReconciler(st, fake, log.New(io.Discard, "", 0)) // default debounceReports = 2
seedReport(t, st, "hD", "cD", stateWaitingSecret) // report #1
r.reconcileOnce(context.Background())
if fake.restages() != 0 {
t.Fatalf("healed on the FIRST waiting_secret report (restages=%d) — debounce broken", fake.restages())
}
// Re-observing the SAME report must also not advance the debounce.
r.reconcileOnce(context.Background())
if fake.restages() != 0 {
t.Fatalf("healed on a RE-OBSERVED same report (restages=%d) — debounce must count distinct reports", fake.restages())
}
seedReport(t, st, "hD", "cD", stateWaitingSecret) // report #2 (distinct)
r.reconcileOnce(context.Background())
if fake.restages() != 1 {
t.Fatalf("did not heal after 2 distinct waiting_secret reports (restages=%d)", fake.restages())
}
}
// E — consumed_failed is escalated (Re-issue), never re-staged (a re-stage re-feeds a burned secret).
func TestScenarioE_ConsumedFailedEscalates(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hE", "cE", true, true)
seedReport(t, st, "hE", "cE", stateConsumedFailed)
fake := &fakeActions{restageResult: true} // even if a secret were re-stageable, must NOT re-stage
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 0 || fake.reissues() != 1 {
t.Fatalf("restages=%d reissues=%d, want 0/1 (consumed_failed → re-issue only)", fake.restages(), fake.reissues())
}
if got := eventTypes(t, st, "cE"); len(got) != 1 || got[0] != eventConsumedFailed {
t.Errorf("events = %v, want [%s] (distinct consumed_failed signal)", got, eventConsumedFailed)
}
}
// F — DR-OFF (disabled descriptor) and enabled-but-unprovisioned hosts are never in the work set.
func TestScenarioF_OutOfScopeHostsUntouched(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hOff", "cOff", false, true) // descriptor disabled
seedReport(t, st, "hOff", "cOff", stateWaitingSecret)
seedHost(t, st, "hUnprov", "cUnprov", true, false) // enabled but never provisioned
seedReport(t, st, "hUnprov", "cUnprov", stateWaitingSecret)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 0 || fake.reissues() != 0 {
t.Fatalf("out-of-scope hosts acted on: restages=%d reissues=%d, want 0/0", fake.restages(), fake.reissues())
}
}
// RestrictToHost scopes the work set: a stuck host OTHER than the allowed one is untouched.
func TestRestrictToHost_ScopesWorkSet(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hAllowed", "cAllowed", true, true)
seedReport(t, st, "hAllowed", "cAllowed", stateWaitingSecret)
seedHost(t, st, "hOther", "cOther", true, true)
seedReport(t, st, "hOther", "cOther", stateWaitingSecret)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.RestrictToHost("hAllowed")
r.reconcileOnce(context.Background())
if fake.restages() != 1 || fake.restageHosts[0] != "hAllowed" {
t.Fatalf("restaged hosts = %v, want exactly [hAllowed] (scope restriction)", fake.restageHosts)
}
}
// After a heal, the SAME stuck report must not re-heal on the next tick (only a fresh report does).
func TestNoReHealOnSameReport(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hR", "cR", true, true)
seedReport(t, st, "hR", "cR", stateWaitingSecret)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.reconcileOnce(context.Background())
r.reconcileOnce(context.Background()) // same report, no new evidence
if fake.restages() != 1 {
t.Fatalf("re-healed the same stuck report: restages=%d, want 1", fake.restages())
}
}
// R-39 — auth_failed is escalated to a fresh mint, never re-staged.
//
// This is the leg that closes the loop end to end: the agent (>=0.91.0) now PROVES the credential is
// rejected instead of silently skipping, and the hub re-keys, which advances the secret generation,
// which moves the descriptor hash, which finally re-arms the agent. A re-stage would re-feed the very
// secret PBS just rejected.
//
// COMPANION RED-PROOF (run + recorded): delete the `case stateAuthFailed` arm → the state falls to
// the default no-op branch and this test FAILS with reissues=0.
func TestR39_AuthFailedEscalatesToReissue(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hAF", "cAF", true, true)
seedReport(t, st, "hAF", "cAF", stateAuthFailed)
fake := &fakeActions{restageResult: true} // a re-stageable secret exists — it must STILL not be used
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if fake.restages() != 0 {
t.Errorf("restages=%d, want 0 — re-staging re-feeds the credential PBS just rejected", fake.restages())
}
if fake.reissues() != 1 {
t.Fatalf("reissues=%d, want 1 — an applied-and-401 tier must be re-keyed, not left green", fake.reissues())
}
if got := eventTypes(t, st, "cAF"); len(got) != 1 || got[0] != eventAuthFailed {
t.Errorf("events = %v, want [%s] (distinct auth_failed signal, not borrowed from consumed_failed)", got, eventAuthFailed)
}
}
// The damper is the burn protection: a 401 flap must not become a secret-minting chain. auth_failed
// uses the SAME confirm() debounce as the other triggers — one report is not enough.
func TestR39_AuthFailedIsDebounced(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hAF2", "cAF2", true, true)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.debounceReports = 2 // require two DISTINCT stuck reports
seedReport(t, st, "hAF2", "cAF2", stateAuthFailed)
r.reconcileOnce(context.Background())
if fake.reissues() != 0 {
t.Fatalf("re-issued on the FIRST auth_failed report (reissues=%d) — the damper is bypassed", fake.reissues())
}
seedReport(t, st, "hAF2", "cAF2", stateAuthFailed) // a second, distinct report
r.reconcileOnce(context.Background())
if fake.reissues() != 1 {
t.Fatalf("reissues=%d after two distinct auth_failed reports, want 1", fake.reissues())
}
// And the same confirmed report must not re-heal on the next tick.
r.reconcileOnce(context.Background())
if fake.reissues() != 1 {
t.Errorf("re-healed on a stale report: reissues=%d, want 1", fake.reissues())
}
}
// A host that recovers (auth_failed → applied) is a pure no-op, and its streak is forgotten.
func TestR39_AuthFailedRecoveryIsNoOp(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hAF3", "cAF3", true, true)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.debounceReports = 2 // so a single stuck report is not yet actionable
seedReport(t, st, "hAF3", "cAF3", stateAuthFailed)
r.reconcileOnce(context.Background()) // streak 1 — below the damper, nothing done
seedReport(t, st, "hAF3", "cAF3", "applied") // the box healed on its own
r.reconcileOnce(context.Background())
if fake.reissues() != 0 || fake.restages() != 0 {
t.Errorf("a recovered host was acted on: restages=%d reissues=%d, want 0/0", fake.restages(), fake.reissues())
}
// The streak must be FORGOTTEN, not merely paused: a later single auth_failed must start over
// rather than instantly tripping the damper it had half-filled before recovering.
seedReport(t, st, "hAF3", "cAF3", stateAuthFailed)
r.reconcileOnce(context.Background())
if fake.reissues() != 0 {
t.Errorf("a stale pre-recovery streak survived and fired early: reissues=%d, want 0", fake.reissues())
}
}
// R-39 Scenario F — the consumed_at HONESTY gauge.
//
// The July-18 fingerprint is a disagreement no single tier can see: the hub staged a credential, the
// box never took it, and the box nonetheless reports `applied`. This surfaces it. It deliberately
// does NOT auto-heal — minting on top of an unconsumed secret is the mint/consume race R-39(a)
// already recorded.
//
// COMPANION RED-PROOF (run + recorded): delete the r.checkUnconsumed(row) call → no event is written
// and TestR39_UnconsumedSecretUnderAppliedIsSurfaced FAILS.
func TestR39_UnconsumedSecretUnderAppliedIsSurfaced(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hU", "cU", true, true)
seedReport(t, st, "hU", "cU", "applied") // the box claims converged
if _, err := st.SaveHostPBSSecret("hU", "never-taken"); err != nil {
t.Fatalf("mint: %v", err)
}
// Age the staged secret past the grace window.
ageSecret(t, st, "hU", 3*time.Hour)
fake := &fakeActions{restageResult: true}
r := newRec(st, fake)
r.reconcileOnce(context.Background())
if got := eventTypes(t, st, "cU"); len(got) != 1 || got[0] != eventUnconsumed {
t.Fatalf("events = %v, want [%s]", got, eventUnconsumed)
}
// SURFACE, not heal: an applied box is not in any heal arm, so nothing was minted or re-staged.
if fake.reissues() != 0 || fake.restages() != 0 {
t.Errorf("the honesty gauge must not mutate: restages=%d reissues=%d, want 0/0", fake.restages(), fake.reissues())
}
// One event per DISTINCT report — a sustained disagreement must not spam the audit log.
r.reconcileOnce(context.Background())
if got := eventTypes(t, st, "cU"); len(got) != 1 {
t.Errorf("re-surfaced on the same report: %d events, want 1", len(got))
}
}
// A freshly staged secret (or a just-restaged one) is NORMAL inside the grace window and must not
// alarm — RestageHostPBSSecret deliberately sets consumed_at back to NULL.
func TestR39_FreshlyStagedSecretDoesNotAlarm(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hF", "cF", true, true)
seedReport(t, st, "hF", "cF", "applied")
if _, err := st.SaveHostPBSSecret("hF", "just-minted"); err != nil {
t.Fatalf("mint: %v", err)
}
// A deliberate re-stage clears consumed_at — the Scenario-F edge case.
if _, err := st.ConsumeHostPBSSecret("hF"); err != nil {
t.Fatalf("consume: %v", err)
}
if _, err := st.RestageHostPBSSecret("hF"); err != nil {
t.Fatalf("restage: %v", err)
}
r := newRec(st, &fakeActions{restageResult: true})
r.reconcileOnce(context.Background())
if got := eventTypes(t, st, "cF"); len(got) != 0 {
t.Errorf("a just-staged secret alarmed inside the grace window: %v", got)
}
}
// A CONSUMED secret never alarms, however old.
func TestR39_ConsumedSecretNeverAlarms(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hC", "cC", true, true)
seedReport(t, st, "hC", "cC", "applied")
if _, err := st.SaveHostPBSSecret("hC", "taken"); err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := st.ConsumeHostPBSSecret("hC"); err != nil {
t.Fatalf("consume: %v", err)
}
ageSecret(t, st, "hC", 30*24*time.Hour)
r := newRec(st, &fakeActions{restageResult: true})
r.reconcileOnce(context.Background())
if got := eventTypes(t, st, "cC"); len(got) != 0 {
t.Errorf("a consumed secret alarmed: %v", got)
}
}
// A box that HONESTLY reports a stuck state is already being healed; its unconsumed secret is the
// symptom, not a contradiction, so the gauge stays quiet and only the heal event is written.
func TestR39_HonestStuckStateDoesNotDoubleReport(t *testing.T) {
st := newHealStore(t)
seedHost(t, st, "hH", "cH", true, true)
seedReport(t, st, "hH", "cH", stateWaitingSecret)
if _, err := st.SaveHostPBSSecret("hH", "staged"); err != nil {
t.Fatalf("mint: %v", err)
}
ageSecret(t, st, "hH", 3*time.Hour)
r := newRec(st, &fakeActions{restageResult: true})
r.reconcileOnce(context.Background())
for _, e := range eventTypes(t, st, "cH") {
if e == eventUnconsumed {
t.Error("an honestly-stuck box must not also raise the honesty gauge — it is the symptom being healed")
}
}
}