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:
@@ -131,6 +131,7 @@
|
||||
| Web POST action | hub/internal/web/configs.go `handleSetGlobalFloor` (~L602) | CSRF enforced centrally in web ServeHTTP; validate via `normalize*` helper; POST-redirect-GET with `?flash=` token; log INFO on success. |
|
||||
| Optional dependency injection | hub/internal/web/server.go `Set*` setters (~L131–148) / api handler `SetDispatcher` etc. | Constructor takes hard deps; optional subsystems wired via `SetX` after construction; nil = graceful degradation (never panic). |
|
||||
| Seam-injected sender for tests | hub/internal/notify/dispatcher.go `sendEmailFn` (~L33) | Function-field defaulting to the real impl in the constructor; tests overwrite it. Same idea: `mailRateLimiter.now`, `mailrelay.Sender` fake. |
|
||||
| Immediate-sync notify (per plane) | agent plane: `poke.Notifier` (`web.Server.poke` / `api.Handler.poker` via `SetPoke`/`SetPoker`) — controller plane: `intent.Hub.Bump` (`web.Server.bumpIntent`, `api.Handler.intentHub`) | EVERY desired-state mutation fires the RIGHT plane's notifier AFTER the successful store write, never on an error path (fire-after-commit). Agent-plane pokes a HOST when its generation moved (`SetHostDesired`/`Bump*HostDesired`); controller-plane bumps a CUSTOMER on a controller-visible change. Both receivers COALESCE bursts — add NO hub-side dedup. Deliberate non-sites need a documented reason (undeliverable pre-tunnel, transport removed, or no generation bump → the 60 s ticker is the pickup path). Both seams nil-safe: unset = the ≤15-min cycle still reconciles. Full site inventory: REPORT.md audit table (v0.63.0). |
|
||||
| Website page | website/index.html | UTF-8 **with BOM**; shared `<nav>`/`<footer>` byte-identical across pages (only `class="active"` differs); two-tone H1 = `<h1>…<span>accent</span></h1>`; all styling in website/assets/site.css tokens (`:root`) — zero embedded `<style>`; `?v=N` cache-bust on site.css/icons.svg; umami snippet; no CDN fonts; no emoji (sprite icons.svg instead). |
|
||||
| Gate script | scripts/site_gates.py | Byte-level mechanical gates (BOM, emoji codepoint ranges, nav/footer diff, analytics, banned tokens, cache-bust); run `python scripts/site_gates.py` after ANY website change; non-zero exit on failure. |
|
||||
| Fetch-validate-install (shell) | scripts/felhom-host-install.sh `step_agent_install` (~L1108) | `fetch_raw` to mktemp → syntax-check (`bash -n`) → `install -m0755 -o root -g root` → only then activate; guarded-mkfs wrapper installed BEFORE the sudoers that references it (ordering is the safety property). All mutations through `run()` (dry-run aware). |
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.63.0 — system-initiated immediacy: wire the proven poke/bump notifiers into every mutation site that lacked one (2026-07-17)
|
||||
|
||||
The immediate-sync arc (Dir-1 trigger, Dir-2b wait channel, Dir-2a agent poke) covered only
|
||||
**operator-initiated** desired-state changes. **System-initiated** mutations still bumped the
|
||||
generation silently, so a freshly onboarded box waited a full agent tick (≤15 min) for state the hub
|
||||
had already minted — observed live during slice-C onboarding. This wires the existing, live-proven
|
||||
notifiers (`poke.Notifier` for the agent plane, `intent.Hub.Bump` for the controller plane) into
|
||||
every system-initiated site that lacked one. No new mechanism — call-site wiring only.
|
||||
|
||||
- **Agent-plane pokes (`internal/web/pbsdr.go`):** `PBSDRAutoProvision` (the exact observed lag — the
|
||||
WG-registration hands-free provision) now pokes on success; `ReissuePBSDR` (the shared core, which
|
||||
also gives the **pbsdrheal reconciler's escalation** its immediacy with zero reconciler changes) and
|
||||
the operator button `handlePBSDRReissue` poke after their descriptor bump. All fire ONLY after the
|
||||
successful `SetHostDesired`, never on a blocked/error path.
|
||||
- **Agent-plane Poker seam (`internal/api/handler.go`, `internal/api/wg.go`):** a new nil-safe `Poker`
|
||||
interface (`PokeHost`/`PokeAllHosts`, satisfied by `*poke.Notifier`) + `SetPoker`. `handleAdminSetDesiredState`
|
||||
pokes the target host after a successful admin desired-state write; `handleAdminSetOperatorPeer` fires
|
||||
a fleet `PokeAllHosts` — but only when `BumpAllHostGenerations` succeeded (fire-after-commit).
|
||||
- **Controller-plane bump (`internal/api/handler.go`):** `reissueOnReenroll` (the clean-slate F2 claim +
|
||||
F3 offsite re-issue) now `intentHub.Bump`s the customer so a long-polling controller wakes in seconds
|
||||
instead of on the 15-min cycle. Nil-guarded; one unconditional bump (coalesced, over-bump harmless).
|
||||
- **main.go:** one `poke.Notifier` instance now feeds BOTH planes' system sites — `webServer.SetPoke(n)`
|
||||
**and** `apiHandler.SetPoker(n)`; startup log: "web + api admin seams armed".
|
||||
- **Deliberate non-sites (unchanged, documented in REPORT audit table):** WG peer *register* (box tunnel
|
||||
doesn't exist pre-fetch — a poke is undeliverable; the agent fast-tick SECONDARY owns this leg), WG peer
|
||||
*delete* (the mutation removes the transport), and the **pbsdrheal Restage** path (no generation bump →
|
||||
the agent's 60 s pbsdr ticker is the pickup path; a poke there is a verified no-op). `internal/pbsdrheal/`
|
||||
is byte-unchanged.
|
||||
- **Tests + red-proofs (run-fail-revert):** 10 new non-hollow tests. Web (async, channel-synchronized
|
||||
fake sender): auto-provision pokes the resolved WG /32, blocked-precondition pokes nothing, reissue-core
|
||||
+ operator-button poke, a failed reissue pokes nothing. API (synchronous fake Poker): admin-set pokes
|
||||
the target host only (0 on invalid-JSON), operator-peer fires one fleet poke, nil-seam no-panic; re-enroll
|
||||
advances the intent generation, nil-hub no-panic. Red-proofs demonstrated one representative removal per
|
||||
group (A/B web pokes, C admin poke, D re-enroll bump) — each FAILED red, then restored. Green:
|
||||
`go build/vet/test` all pass.
|
||||
|
||||
## v0.62.0 — R-21 slice C: the universal ISO — unclaimed-appliance registration + operator bind + one-shot delivery (2026-07-17)
|
||||
|
||||
The hub half of the universal, **secret-free** bare-metal ISO. A box booted from the generic ISO
|
||||
|
||||
+6
-2
@@ -444,8 +444,12 @@ func main() {
|
||||
}, logger); err != nil {
|
||||
logger.Printf("[ERROR] agent-plane poke disabled: %v", err)
|
||||
} else {
|
||||
webServer.SetPoke(poke.NewNotifier(dataStore, pokeClient, logger))
|
||||
logger.Printf("[INFO] agent-plane poke enabled (endpoint %s, user %s)", wgAddr, wgUser)
|
||||
// One notifier, both planes' system-initiated sites (v0.63.0): the web server's
|
||||
// pbsdr writes AND the api handler's admin desired-state / operator-peer writes.
|
||||
n := poke.NewNotifier(dataStore, pokeClient, logger)
|
||||
webServer.SetPoke(n)
|
||||
apiHandler.SetPoker(n)
|
||||
logger.Printf("[INFO] agent-plane poke enabled (endpoint %s, user %s; web + api admin seams armed)", wgAddr, wgUser)
|
||||
}
|
||||
} else {
|
||||
logger.Printf("[INFO] agent-plane poke disabled (key or endpoint not configured)")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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