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.
198 lines
6.3 KiB
Go
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
|