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:
@@ -310,6 +310,7 @@ func (s *Server) PBSDRAutoProvision(ctx context.Context, customerID string) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr auto-provisioned for %s on WG registration (hands-free cascade)", customerID)
|
||||
s.poke.PokeHost(host.HostID) // freshly auto-provisioned + generation bumped → nudge the box now (the observed slice-C lag)
|
||||
}
|
||||
|
||||
// ReissuePBSDR re-keys the customer's ep0 PBS token and re-arms the agent — the non-HTTP core shared
|
||||
@@ -357,6 +358,7 @@ func (s *Server) ReissuePBSDR(ctx context.Context, customerID string) error {
|
||||
return fmt.Errorf("pbsdr reissue for %s: descriptor bump: %w", customerID, err)
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr credentials re-issued for %s (host %s; fresh consume-once secret stored, withheld from logs)", customerID, host.HostID)
|
||||
s.poke.PokeHost(host.HostID) // agent-plane immediate-sync (Direction-2a): re-consume signal lands in seconds (also the pbsdrheal escalation path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -412,6 +414,7 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr credentials re-issued for %s (host %s; fresh consume-once secret stored)", customerID, host.HostID)
|
||||
s.poke.PokeHost(host.HostID) // agent-plane immediate-sync (Direction-2a): re-consume signal lands in seconds
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package web
|
||||
|
||||
// v0.63.0 system-initiated immediacy — the agent-plane pokes wired into the pbsdr mutation sites.
|
||||
// These assert the EFFECT (the resolved WG /32 received a nudge), synchronize on a channel with a
|
||||
// bounded timeout (never sleep-poll), and carry explicit zero-count negatives on the error/blocked
|
||||
// paths. The notifier is a REAL poke.Notifier built over the test store + a channel-carrying fake
|
||||
// sender (structural satisfaction of poke's unexported sender seam — the value is passed, the type
|
||||
// is never named), exactly as main.go builds it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
|
||||
)
|
||||
|
||||
func quietWebLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
// chanSender is a fake poke sender: it records every target and, for async assertions, publishes
|
||||
// each one on a buffered channel so a test can select-with-timeout instead of sleeping.
|
||||
type chanSender struct {
|
||||
mu sync.Mutex
|
||||
targets []string
|
||||
ch chan string
|
||||
err error
|
||||
}
|
||||
|
||||
func newChanSender() *chanSender { return &chanSender{ch: make(chan string, 8)} }
|
||||
|
||||
func (f *chanSender) Poke(_ context.Context, boxWGIP string) error {
|
||||
f.mu.Lock()
|
||||
f.targets = append(f.targets, boxWGIP)
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case f.ch <- boxWGIP:
|
||||
default:
|
||||
}
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *chanSender) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.targets)
|
||||
}
|
||||
|
||||
// wirePoke builds a real notifier over the test store + fake sender and installs it on the server.
|
||||
func wirePoke(s *Server, st *store.Store, snd *chanSender) {
|
||||
s.SetPoke(poke.NewNotifier(st, snd, quietWebLogger()))
|
||||
}
|
||||
|
||||
// expectPoke asserts exactly one poke to wantIP arrives within the bound, and none trails it.
|
||||
func expectPoke(t *testing.T, snd *chanSender, wantIP string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-snd.ch:
|
||||
if got != wantIP {
|
||||
t.Fatalf("poke target = %q, want %q", got, wantIP)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("no poke within 2s (want one to %s)", wantIP)
|
||||
}
|
||||
// No second poke should follow (a short settle window; the send is buffered so a stray fires fast).
|
||||
select {
|
||||
case extra := <-snd.ch:
|
||||
t.Fatalf("a second, unexpected poke fired to %q", extra)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoPoke asserts no poke arrives within a bounded settle window (the negative-path contract).
|
||||
func expectNoPoke(t *testing.T, snd *chanSender) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-snd.ch:
|
||||
t.Fatalf("a poke fired to %q on a path that must not poke", got)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if n := snd.count(); n != 0 {
|
||||
t.Fatalf("sender received %d pokes, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func peerIP(t *testing.T, st *store.Store, hostID string) string {
|
||||
t.Helper()
|
||||
p, err := st.GetWGPeerForHost(hostID)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("peer for %s: %v", hostID, err)
|
||||
}
|
||||
return p.AssignedIP
|
||||
}
|
||||
|
||||
// Scenario A — the observed slice-C lag: PBSDRAutoProvision pokes the box after a hands-free
|
||||
// provision. Red-proof: remove the 1.1 insertion → expectPoke times out.
|
||||
func TestPBSDR_AutoProvisionPokes(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "AUTO-SECRET"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
||||
t.Fatalf("remove seed peer: %v", err)
|
||||
}
|
||||
// Flag ON while the peer is missing → stored intent, no provision (and so no poke path).
|
||||
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||
|
||||
// The agent registers its WG key; the hook fires the atom → provision succeeds → poke.
|
||||
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
|
||||
t.Fatalf("register peer: %v", err)
|
||||
}
|
||||
snd := newChanSender()
|
||||
wirePoke(s, st, snd)
|
||||
wantIP := peerIP(t, st, "peti-01")
|
||||
|
||||
s.PBSDRAutoProvision(context.Background(), "peti")
|
||||
expectPoke(t, snd, wantIP)
|
||||
}
|
||||
|
||||
// Scenario A (negative) — a blocked precondition (no WG peer) mints nothing, so NO poke fires.
|
||||
func TestPBSDR_AutoProvisionBlockedDoesNotPoke(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
||||
t.Fatalf("remove seed peer: %v", err)
|
||||
}
|
||||
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||
|
||||
snd := newChanSender()
|
||||
wirePoke(s, st, snd)
|
||||
s.PBSDRAutoProvision(context.Background(), "peti") // peer still absent → blocked, no mint
|
||||
expectNoPoke(t, snd)
|
||||
}
|
||||
|
||||
// Scenario B1 — ReissuePBSDR (the reconciler-escalation core) pokes after the descriptor bump.
|
||||
// Red-proof: remove the 1.2 insertion → expectPoke times out.
|
||||
func TestPBSDR_ReissueCorePokes(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "OLD"}
|
||||
s, st, _ := newPBSDRServer(t, fake) // poke NOT wired yet → the provision below fires none
|
||||
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||
st.ConsumeHostPBSSecret("peti-01")
|
||||
|
||||
snd := newChanSender()
|
||||
wirePoke(s, st, snd)
|
||||
wantIP := peerIP(t, st, "peti-01")
|
||||
|
||||
fake.secret = "FRESH"
|
||||
if err := s.ReissuePBSDR(context.Background(), "peti"); err != nil {
|
||||
t.Fatalf("ReissuePBSDR: %v", err)
|
||||
}
|
||||
expectPoke(t, snd, wantIP)
|
||||
}
|
||||
|
||||
// Scenario B (negative) — a reissue that FAILS (tenantsync error) never reaches SetHostDesired,
|
||||
// so NO poke fires (fire-after-commit).
|
||||
func TestPBSDR_ReissueCoreErrorDoesNotPoke(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "OLD"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||
|
||||
snd := newChanSender()
|
||||
wirePoke(s, st, snd)
|
||||
|
||||
fake.err = errors.New("ssh boom")
|
||||
if err := s.ReissuePBSDR(context.Background(), "peti"); err == nil {
|
||||
t.Fatal("ReissuePBSDR returned nil on a tenantsync error")
|
||||
}
|
||||
expectNoPoke(t, snd)
|
||||
}
|
||||
|
||||
// Scenario B2 — the operator button (handlePBSDRReissue) pokes after the descriptor bump.
|
||||
func TestPBSDR_HandleReissuePokes(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "OLD"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||
st.ConsumeHostPBSSecret("peti-01")
|
||||
|
||||
snd := newChanSender()
|
||||
wirePoke(s, st, snd)
|
||||
wantIP := peerIP(t, st, "peti-01")
|
||||
|
||||
fake.secret = "FRESH"
|
||||
req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handlePBSDRReissue(rr, req, "peti")
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("reissue = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
expectPoke(t, snd, wantIP)
|
||||
}
|
||||
|
||||
var _ = tenantsync.ErrTokenExists // keep the tenantsync import stable across edits
|
||||
Reference in New Issue
Block a user