30972d8f54
The immediate-sync arc covered only operator-initiated desired-state changes; system-initiated mutations bumped the generation silently, so a freshly onboarded box waited a full agent tick for state the hub had already minted (observed live at slice-C onboarding). Wire the existing, live-proven notifiers into every system site on the correct plane — call-site wiring only, no new mechanism. Agent plane (poke.Notifier): - web/pbsdr.go: PBSDRAutoProvision (the observed lag), ReissuePBSDR (also lifts the pbsdrheal reconciler escalation, zero reconciler changes), handlePBSDRReissue — each pokes AFTER the successful SetHostDesired, never on a blocked/error path. - api: new nil-safe Poker seam (PokeHost/PokeAllHosts + SetPoker); handleAdminSetDesiredState pokes the target host; handleAdminSetOperatorPeer fires PokeAllHosts only when the fleet generation bump succeeded (fire-after-commit). - main.go: one poke.Notifier now feeds both planes (SetPoke + SetPoker). Controller plane (intent.Hub.Bump): - api/reissueOnReenroll: one nil-guarded bump so a long-polling controller wakes in seconds instead of on the 15-min cycle. Deliberate non-sites (unchanged): WG register (undeliverable pre-tunnel — the agent fast-tick SECONDARY owns it), WG delete (transport removed), pbsdrheal Restage (no generation bump → the 60s ticker is the pickup path). internal/pbsdrheal byte-unchanged. Tests: 10 non-hollow tests (web async channel-synchronized fake sender; api synchronous fake Poker) with explicit zero-count negatives; representative red-proofs per group (A/B/C/D) run-fail-restored. Green: go build/vet/test all pass.
133 lines
4.9 KiB
Go
133 lines
4.9 KiB
Go
package api
|
|
|
|
// v0.63.0 system-initiated immediacy — the api-side agent-plane pokes (admin desired-state write,
|
|
// operator-peer bump) and the controller-plane intent bump on clean-slate re-enroll. The api sites
|
|
// call the Poker SYNCHRONOUSLY, so a counting fake is deterministic right after the request. The
|
|
// negatives (error/invalid paths, nil seams) carry explicit zero-count / no-panic assertions.
|
|
|
|
import (
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// fakePoker counts calls per method and records PokeHost targets. Satisfies api.Poker.
|
|
type fakePoker struct {
|
|
mu sync.Mutex
|
|
hostCalls []string
|
|
allCalls int
|
|
}
|
|
|
|
func (f *fakePoker) PokeHost(hostID string) {
|
|
f.mu.Lock()
|
|
f.hostCalls = append(f.hostCalls, hostID)
|
|
f.mu.Unlock()
|
|
}
|
|
func (f *fakePoker) PokeAllHosts() {
|
|
f.mu.Lock()
|
|
f.allCalls++
|
|
f.mu.Unlock()
|
|
}
|
|
func (f *fakePoker) hosts() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]string(nil), f.hostCalls...)
|
|
}
|
|
func (f *fakePoker) all() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.allCalls
|
|
}
|
|
|
|
// C1 — a successful admin desired-state write pokes exactly the target host (and never the fleet);
|
|
// the invalid-JSON path pokes nothing (fire-after-commit).
|
|
func TestAdminSetDesiredState_PokesTargetHostOnly(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "HKEY1")
|
|
p := &fakePoker{}
|
|
h.SetPoker(p)
|
|
|
|
if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[]}`); rr.Code != http.StatusOK {
|
|
t.Fatalf("admin-set = %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if got := p.hosts(); len(got) != 1 || got[0] != "h1" {
|
|
t.Fatalf("PokeHost targets = %v, want [h1]", got)
|
|
}
|
|
if p.all() != 0 {
|
|
t.Errorf("PokeAllHosts = %d, want 0 (a single-host write is not a fleet nudge)", p.all())
|
|
}
|
|
|
|
// Invalid JSON is rejected at the door → NO poke.
|
|
before := len(p.hosts())
|
|
if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `not json`); rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("malformed admin-set = %d, want 400", rr.Code)
|
|
}
|
|
if len(p.hosts()) != before {
|
|
t.Errorf("a poke fired on the invalid-JSON path (targets now %v)", p.hosts())
|
|
}
|
|
}
|
|
|
|
// C1 (nil seam) — an unset poker leaves the mutation intact and never panics.
|
|
func TestAdminSetDesiredState_NilPokerNoPanic(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "HKEY1")
|
|
// No SetPoker — h.poker is nil.
|
|
if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[]}`); rr.Code != http.StatusOK {
|
|
t.Fatalf("admin-set with nil poker = %d, want 200 (mutation must still succeed)", rr.Code)
|
|
}
|
|
}
|
|
|
|
// C2 — a successful operator-peer write bumps EVERY host generation → exactly one fleet poke, and
|
|
// never a single-host poke. (The BumpAllHostGenerations-error negative is assert-by-inspection: the
|
|
// poke sits in the `else if h.poker != nil` arm of the bump result, so a bump error skips it — there
|
|
// is no non-destructive seam to force a store bump failure in this fixture.)
|
|
func TestAdminSetOperatorPeer_PokesFleet(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "HKEY1")
|
|
putTestEndpoint(t, h)
|
|
p := &fakePoker{}
|
|
h.SetPoker(p)
|
|
|
|
if rr := do(h, http.MethodPut, "/admin/wg/operator-peer", globalKey,
|
|
`{"pubkey":"`+opTestPubkey+`","assigned_ip":"10.77.0.250"}`); rr.Code != http.StatusOK {
|
|
t.Fatalf("set operator peer = %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if p.all() != 1 {
|
|
t.Errorf("PokeAllHosts = %d, want 1", p.all())
|
|
}
|
|
if got := p.hosts(); len(got) != 0 {
|
|
t.Errorf("PokeHost fired on a fleet-wide change: %v", got)
|
|
}
|
|
}
|
|
|
|
// Scenario D — clean-slate re-enroll bumps the controller-plane intent so a long-polling controller
|
|
// wakes in seconds. reissueOnReenroll is synchronous, so the generation advances by return time.
|
|
// Red-proof: remove the Part-3 bump → Generation stays 0.
|
|
func TestReenroll_BumpsIntent(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
hub := intent.New()
|
|
h.SetIntentHub(hub)
|
|
|
|
gen0 := hub.Generation("c1")
|
|
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
|
|
t.Fatalf("enroll = %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if gen := hub.Generation("c1"); gen <= gen0 {
|
|
t.Fatalf("intent generation = %d, want > %d (re-enroll must bump)", gen, gen0)
|
|
}
|
|
}
|
|
|
|
// Scenario D (nil seam) — with no intent hub wired, enroll still succeeds and never panics.
|
|
func TestReenroll_NilIntentHubNoPanic(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
// No SetIntentHub — h.intentHub is nil.
|
|
if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
|
|
t.Fatalf("enroll with nil intent hub = %d, want 201", rr.Code)
|
|
}
|
|
}
|