hub v0.51.0: DR-tier-by-default — per-customer dr_tier flag (default ON, legacy backfill from reality), cascade stages, WG-registration auto-provision hook, offsite-requires-DR guard (F-6 policy), host-page capability chips (inactive=neutral)
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
+103
-33
@@ -91,11 +91,18 @@ func mergePBSDR(desiredJSON string, d *pbsDRDescriptor) (string, error) {
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// applyPBSDR handles the config form's PBS DR section on create/update. Called BEFORE
|
||||
// SaveCustomerConfig (fail-closed: an error must abort the whole save). The descriptor lives in
|
||||
// the HOST's desired_json — ConfigJSON never carries pbs_dr (single source of truth).
|
||||
// applyPBSDR handles the config form's DR-tier section on create/update. Called BEFORE
|
||||
// SaveCustomerConfig (fail-closed on REAL provisioning failures: those abort the whole save).
|
||||
// The descriptor lives in the HOST's desired_json — ConfigJSON never carries pbs_dr.
|
||||
//
|
||||
// v0.51.0 (DR-tier-by-default): the switch is cfg.DRTier (the stored per-customer flag, set from
|
||||
// the form by the handler) — and an UNMET PRECONDITION is no longer an error. New customers
|
||||
// default the flag ON before any host exists; the cascade converges later: host enrolls → WG
|
||||
// peer registers (the api hook auto-provisions, scenario A) → descriptor applies on the agent's
|
||||
// next tick. The form save stores the intent and reports the honest waiting stage; only an
|
||||
// actual provisioning FAILURE (tenantsync error, token mismatch…) still fails the save.
|
||||
func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
|
||||
enabled := r.FormValue("pbsdr_enabled") == "on" || r.FormValue("pbsdr_enabled") == "true"
|
||||
enabled := cfg.DRTier
|
||||
storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id"))
|
||||
if storageID == "" {
|
||||
storageID = defaultPBSStorageID
|
||||
@@ -106,10 +113,10 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
||||
return fmt.Errorf("pbsdr: host lookup: %w", err)
|
||||
}
|
||||
if host == nil {
|
||||
if !enabled {
|
||||
return nil // nothing enrolled, nothing enabled — nothing to do
|
||||
if enabled {
|
||||
s.logger.Printf("[INFO] pbsdr: DR tier ON for %s, no host enrolled yet — the descriptor applies once the cascade is ready (host → WG peer → apply)", cfg.CustomerID)
|
||||
}
|
||||
return fmt.Errorf("no host enrolled for customer %s yet — the PBS DR tier needs the enrolled host", cfg.CustomerID)
|
||||
return nil // the flag is stored; nothing host-side to converge yet
|
||||
}
|
||||
cur := readPBSDR(host.DesiredJSON)
|
||||
|
||||
@@ -156,20 +163,38 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fresh provision. Fail-closed preconditions first.
|
||||
// Fresh provision — the shared atom (also fired by the WG-registration hook, scenario A).
|
||||
// An unmet precondition is an honest waiting stage, never a save-blocking error.
|
||||
blocked, err := s.pbsdrProvisionAtom(ctx, cfg.CustomerID, host, storageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked != "" {
|
||||
s.logger.Printf("[INFO] pbsdr: DR tier ON for %s — waiting: %s", cfg.CustomerID, blocked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pbsdrProvisionAtom runs the fresh-provision cascade step for a host with NO provisioned
|
||||
// descriptor yet: preconditions → tenantsync provision → consume-once secret → descriptor merge
|
||||
// + generation bump. Returns (blockedReason, err): a non-empty blockedReason is an HONEST
|
||||
// waiting stage (tunnel peer absent, endpoint unset, tenantsync unconfigured — the cascade
|
||||
// retries at its next opportunity); err is a REAL provisioning failure and must stay loud
|
||||
// (fail-closed on the form path, error-logged on the hook path).
|
||||
func (s *Server) pbsdrProvisionAtom(ctx context.Context, customerID string, host *store.Host, storageID string) (string, error) {
|
||||
if s.tenantsync == nil {
|
||||
return fmt.Errorf("PBS DR provisioning is not configured on this hub (no tenantsync key)")
|
||||
return "PBS DR provisioning is not configured on this hub (no tenantsync key)", nil
|
||||
}
|
||||
if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows {
|
||||
return fmt.Errorf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID)
|
||||
return fmt.Sprintf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID), nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("pbsdr: wg peer lookup: %w", err)
|
||||
return "", fmt.Errorf("pbsdr: wg peer lookup: %w", err)
|
||||
}
|
||||
ep, err := s.store.GetWGEndpoint()
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("wg endpoint not configured — register ep0 before the PBS DR tier")
|
||||
return "wg endpoint not configured — register ep0 before the PBS DR tier", nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("pbsdr: wg endpoint read: %w", err)
|
||||
return "", fmt.Errorf("pbsdr: wg endpoint read: %w", err)
|
||||
}
|
||||
|
||||
// Detach from the request context (the applyOffsite F1 precedent): once provisioning starts,
|
||||
@@ -178,19 +203,19 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
||||
// manual re-issue). The absolute timeout still bounds a hung SSH exec.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.tenantsync.Provision(ctx, cfg.CustomerID)
|
||||
res, err := s.tenantsync.Provision(ctx, customerID)
|
||||
if errors.Is(err, tenantsync.ErrTokenExists) {
|
||||
// ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a
|
||||
// half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue.
|
||||
return fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", cfg.CustomerID)
|
||||
return "", fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", customerID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal).
|
||||
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
|
||||
return fmt.Errorf("pbsdr: store one-time token secret: %w", err)
|
||||
return "", fmt.Errorf("pbsdr: store one-time token secret: %w", err)
|
||||
}
|
||||
desc := &pbsDRDescriptor{
|
||||
Enabled: true,
|
||||
@@ -203,15 +228,48 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
||||
}
|
||||
merged, err := mergePBSDR(host.DesiredJSON, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||
return "", fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)",
|
||||
cfg.CustomerID, host.HostID, res.Namespace, res.TokenID, gen)
|
||||
return nil
|
||||
customerID, host.HostID, res.Namespace, res.TokenID, gen)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// PBSDRAutoProvision is the WG-registration hook (v0.51.0, scenario A): the api handler calls it
|
||||
// (via the main.go closure) right after a host's first WG peer registration. If the customer's
|
||||
// DR-tier flag is ON and no descriptor is provisioned yet, it runs the provisioning atom — the
|
||||
// descriptor then applies on the agent's next desired-state tick with ZERO operator steps.
|
||||
// Never fails the caller: outcomes are logged (an error here is retried by the next config save).
|
||||
func (s *Server) PBSDRAutoProvision(ctx context.Context, customerID string) {
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil || !cfg.DRTier {
|
||||
return
|
||||
}
|
||||
host, err := s.store.GetHostByCustomer(customerID)
|
||||
if err != nil || host == nil {
|
||||
return
|
||||
}
|
||||
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.Namespace != "" {
|
||||
return // already provisioned — nothing to converge here
|
||||
}
|
||||
storageID := defaultPBSStorageID
|
||||
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.StorageID != "" {
|
||||
storageID = cur.StorageID
|
||||
}
|
||||
blocked, err := s.pbsdrProvisionAtom(ctx, customerID, host, storageID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr auto-provision for %s (WG-registration hook): %v — save the customer config to retry", customerID, err)
|
||||
return
|
||||
}
|
||||
if blocked != "" {
|
||||
s.logger.Printf("[INFO] pbsdr auto-provision for %s still waiting: %s", customerID, blocked)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr auto-provisioned for %s on WG registration (hands-free cascade)", customerID)
|
||||
}
|
||||
|
||||
// handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent):
|
||||
@@ -269,22 +327,28 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// pbsDRView is the config form's render model for the PBS DR section.
|
||||
// pbsDRView is the config form's render model for the DR-tier section, including the v0.51.0
|
||||
// cascade stages (host → WG peer → descriptor → escrow) — one flag, ordered rollout, honest
|
||||
// intermediate states (scenario D).
|
||||
type pbsDRView struct {
|
||||
Supported bool // tenantsync configured on this hub
|
||||
NoHost bool // no enrolled host for the customer (enable would fail-closed)
|
||||
HostID string
|
||||
Enabled bool
|
||||
StorageID string // current or the default
|
||||
Provisioned bool
|
||||
Namespace string
|
||||
TokenID string
|
||||
Supported bool // tenantsync configured on this hub
|
||||
NoHost bool // no enrolled host for the customer (cascade stage 1 waiting)
|
||||
HostID string
|
||||
DRTier bool // the stored per-customer flag (operator INTENT)
|
||||
Enabled bool // descriptor enabled (host-side reality)
|
||||
StorageID string // current or the default
|
||||
Provisioned bool
|
||||
Namespace string
|
||||
TokenID string
|
||||
WGPeer bool // cascade stage 2: the host has registered its tunnel peer
|
||||
EscrowPresent bool // cascade stage 4: the ceremony ran (blob in custody)
|
||||
}
|
||||
|
||||
// pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a
|
||||
// zero-ish view (the section still renders).
|
||||
func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
|
||||
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID}
|
||||
// zero-ish view (the section still renders). drTier is the customer's stored flag (the caller
|
||||
// has the config; "" customerID = the create form).
|
||||
func (s *Server) pbsDRViewFor(customerID string, drTier bool) pbsDRView {
|
||||
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID, DRTier: drTier}
|
||||
if customerID == "" {
|
||||
v.NoHost = true
|
||||
return v
|
||||
@@ -295,6 +359,12 @@ func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
|
||||
return v
|
||||
}
|
||||
v.HostID = host.HostID
|
||||
if _, err := s.store.GetWGPeerForHost(host.HostID); err == nil {
|
||||
v.WGPeer = true
|
||||
}
|
||||
if escrow, err := s.store.GetHostEscrow(host.HostID); err == nil && escrow != nil {
|
||||
v.EscrowPresent = true
|
||||
}
|
||||
if d := readPBSDR(host.DesiredJSON); d != nil {
|
||||
v.Enabled = d.Enabled
|
||||
if d.StorageID != "" {
|
||||
|
||||
Reference in New Issue
Block a user