Files
felhom.eu/hub/internal/web/pbsdr_poke_test.go
T
admin 30972d8f54 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.
2026-07-17 17:30:41 +02:00

198 lines
6.3 KiB
Go

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