feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)
GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub completes it the instant any operator intent bumps that customer's in-memory generation, then the box fires its ordinary report and the ACK delivers everything through the unchanged machinery. 240s hold with a 25s heartbeat newline defeats the nginx 60s proxy_read_timeout with no ingress annotation; WriteTimeout lifted per-connection via ResponseController. - internal/intent: per-customer generation counter + waiter registry (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design. Red-proofs: counter-vs-queue + race-closer (run-fail-reverted). - api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B). - web bumps after every intent write (fire-after-commit): config CRUD, claim resend, offsite re-issue/freeze, password regen, block/unblock, floors (global bumps all config-managed), controller log-tail + log-bundle. - main.go: one intent hub shared by web+api; Close() before server.Shutdown. Pairs with controller v0.140.0 (the long-poll client). Grounding: documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
This commit is contained in:
@@ -561,6 +561,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config created: %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -629,6 +630,7 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config updated: %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -652,6 +654,7 @@ func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, custo
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] claim code re-sent for %s (operator resend; generation rotated)", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: the fresh claim hash rides the next report ACK
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resent#tab=setup", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -696,6 +699,7 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: wake the stuck box to re-pull + re-run the bridge
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -735,6 +739,7 @@ func (s *Server) ReissueOffsiteForCustomer(ctx context.Context, customerID strin
|
||||
return fmt.Errorf("offsite re-issue: config bump: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite credentials re-issued for %s on re-enroll (fresh one-time password; ConfigVersion bumped)", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: the fresh box's first wait wakes on this
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -771,6 +776,7 @@ func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, cus
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite frozen=%v (readonly) for %s (operator action)", frozen, customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: reflect the freeze state change to the box promptly
|
||||
flash := "offsite_frozen"
|
||||
if !frozen {
|
||||
flash = "offsite_unfrozen"
|
||||
@@ -787,6 +793,7 @@ func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, cust
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Customer config deleted: %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
|
||||
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -832,6 +839,7 @@ func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: nudge the box promptly after a credential change
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated#tab=setup", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -848,6 +856,7 @@ func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, cus
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Customer blocked: %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: deliver the blocked flag to the box in seconds
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -864,6 +873,7 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Customer unblocked: %s", customerID)
|
||||
s.bumpIntent(customerID) // Direction-2: clear the blocked flag on the box promptly
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -927,6 +937,13 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Global controller-version floor set to %q", v)
|
||||
// Direction-2: the global floor affects every config-managed customer — wake each long-polling
|
||||
// box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances).
|
||||
if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil {
|
||||
for _, c := range configs {
|
||||
s.bumpIntent(c.CustomerID)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1029,6 +1046,7 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q", customerID, v)
|
||||
s.bumpIntent(customerID) // Direction-2: deliver the new floor to the box in seconds
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ func (s *Server) handleRequestLogBundle(w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] %s log bundle requested for host %s — the box delivers on its next cycle", component, hostID)
|
||||
// Direction-2: only the CONTROLLER ring rides the report ACK (the wait channel wakes the
|
||||
// controller). The AGENT ring rides the heartbeat envelope — a separate plane this task does not
|
||||
// touch — so it is deliberately NOT bumped here.
|
||||
if component == store.LogBundleComponentController {
|
||||
s.bumpIntent(host.CustomerID)
|
||||
}
|
||||
http.Redirect(w, r, "/hosts/"+hostID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func (s *Server) handleRequestLogTail(w http.ResponseWriter, r *http.Request, cu
|
||||
s.logger.Printf("[WARN] SaveEvent log_tail_requested %s/%s: %v", customerID, app, err)
|
||||
}
|
||||
s.logger.Printf("[INFO] Log tail requested for %s/%s — controller delivers on its next report cycle", customerID, app)
|
||||
s.bumpIntent(customerID) // Direction-2: pull the tail in seconds, not on the next cycle
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=log_tail_requested", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -66,6 +67,11 @@ type Server struct {
|
||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||||
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
|
||||
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
|
||||
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
|
||||
// no immediacy (bumps are no-ops; the 15-min cycle still reconciles).
|
||||
intentHub *intent.Hub
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -170,6 +176,19 @@ func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
|
||||
// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
|
||||
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }
|
||||
|
||||
// SetIntentHub wires the operator-intent notifier (v0.58.0). Every intent handler bumps it via
|
||||
// s.bumpIntent; nil-safe (bumps become no-ops).
|
||||
func (s *Server) SetIntentHub(hub *intent.Hub) { s.intentHub = hub }
|
||||
|
||||
// bumpIntent advances the customer's wait generation so a box long-polling GET /api/v1/wait wakes
|
||||
// immediately. nil-safe. Call AFTER the successful store write (mirror the report.Trigger
|
||||
// fire-after-commit rule — never on an error path).
|
||||
func (s *Server) bumpIntent(customerID string) {
|
||||
if s.intentHub != nil {
|
||||
s.intentHub.Bump(customerID)
|
||||
}
|
||||
}
|
||||
|
||||
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
|
||||
// degrades to manual text entry.
|
||||
func (s *Server) SetGiteaClient(c *gitea.Client) {
|
||||
|
||||
Reference in New Issue
Block a user