hub v0.63.0 — system-initiated immediacy: wire poke/bump at every mutation site that lacked one

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.
This commit is contained in:
2026-07-17 17:30:41 +02:00
parent 4c9b0e8706
commit 30972d8f54
8 changed files with 411 additions and 2 deletions
+34
View File
@@ -35,6 +35,16 @@ type LatestVersionProvider interface {
LatestVersion() string
}
// Poker is the agent-plane immediate-sync seam (v0.63.0): satisfied by *poke.Notifier. A system-
// initiated desired-state write here (admin-set, operator-peer bump) fires a contentless, fire-and-
// forget nudge so the box ticks in seconds. nil = poke disabled — mutations still persist; the box
// picks them up on its next ≤15-min cycle. Both methods are safe to call on a nil *poke.Notifier,
// but every call site still guards with `if h.poker != nil` (the field itself may be nil).
type Poker interface {
PokeHost(hostID string)
PokeAllHosts()
}
// Handler handles API endpoints for report ingest and customer queries.
type Handler struct {
store *store.Store
@@ -83,6 +93,12 @@ type Handler struct {
// notifier that GET /api/v1/wait long-polls against. nil = wait endpoint returns 503 (the box
// falls back to the 15-min cycle). Shared with the web server, whose intent handlers Bump it.
intentHub *intent.Hub
// poker (v0.63.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget nudge after a
// system-initiated HOST desired-state write (admin-set desired-state, operator-peer bump) so the
// box ticks in seconds instead of ≤15 min. Shared with the web server (same *poke.Notifier). nil
// = poke disabled (a no-op; the report cycle still reconciles).
poker Poker
}
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
@@ -112,6 +128,12 @@ func (h *Handler) SetIntentHub(hub *intent.Hub) {
h.intentHub = hub
}
// SetPoker wires the agent-plane immediate-sync notifier (v0.63.0; nil-safe — an unset poker makes
// the admin desired-state writes fire no nudge, and the box picks the change up on its next cycle).
func (h *Handler) SetPoker(p Poker) {
h.poker = p
}
// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
return &Handler{
@@ -1051,6 +1073,15 @@ func (h *Handler) reissueOnReenroll(cc *store.CustomerConfig) {
h.logger.Printf("[WARN] offsite re-issue on re-enroll for %s failed: %v", cc.CustomerID, err)
}
}
// Direction-2 (v0.63.0): wake a long-polling controller so the re-staged claim code / offsite
// password ride the next ACK in seconds, not on the 15-min cycle. Both legs above are
// best-effort; an over-bump costs one cheap wake. (On the clean-slate path the controller
// usually does not exist yet — its startup fetch covers that shape; a bump landing during a
// fresh controller's FIRST hold is recorded as baseline without firing — the known open
// observation, fixed later by carrying intent_gen in the report ACK. Out of scope here.)
if h.intentHub != nil {
h.intentHub.Bump(cc.CustomerID)
}
}
// escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob
@@ -1396,6 +1427,9 @@ func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Requ
return
}
h.logger.Printf("[INFO] admin-set desired-state for host %s (generation now %d, %d bytes)", pathHostID, gen, len(body))
if h.poker != nil {
h.poker.PokeHost(pathHostID) // agent-plane immediate-sync (Direction-2a): generation bumped → nudge the box now
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "generation": gen})
+132
View File
@@ -0,0 +1,132 @@
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)
}
}
+2
View File
@@ -504,6 +504,8 @@ func (h *Handler) handleAdminSetOperatorPeer(w http.ResponseWriter, r *http.Requ
bumped, berr := h.store.BumpAllHostGenerations()
if berr != nil {
h.logger.Printf("[WARN] operator peer set but generation bump failed: %v", berr)
} else if h.poker != nil {
h.poker.PokeAllHosts() // agent-plane immediate-sync (Direction-2a): every host generation moved → fleet nudge (fire-after-commit)
}
h.logger.Printf("[INFO] operator OOB peer set: %s -> %s/32 (sync=%s, %d host generations bumped)",
req.Pubkey, req.AssignedIP, syncStatus, bumped)