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:
2026-07-12 20:37:00 +02:00
parent 007946faf4
commit 448a68237a
18 changed files with 824 additions and 87 deletions
+72 -11
View File
@@ -142,6 +142,15 @@ func (s *Store) migrate() error {
// at 1, so an already-running box records that as its baseline on its next report without restarting.
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1")
// v0.51.0 (DR-tier-by-default): per-customer DR-tier flag. NEW customers default ON (the
// create handler sets it); the column default 0 is the LEGACY initialization — an existing
// customer is only flipped ON by the one-time backfill (initialize from reality: its host
// already carries an ENABLED pbs_dr descriptor; never auto-cascade a legacy box). The
// backfill runs exactly once — only when the ALTER actually added the column — and is
// DEFERRED to the end of migrate(): the hosts table it reads is created further down (a
// fresh DB reaches the ALTER before the CREATE).
_, drTierAlterErr := s.db.Exec("ALTER TABLE customer_configs ADD COLUMN dr_tier INTEGER NOT NULL DEFAULT 0")
// v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive
// restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_
// CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it.
@@ -546,6 +555,14 @@ func (s *Store) migrate() error {
return err
}
// v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
if drTierAlterErr == nil {
if err := s.backfillDRTierFromDescriptors(); err != nil {
return fmt.Errorf("dr_tier backfill: %w", err)
}
}
return nil
}
@@ -892,6 +909,43 @@ func (s *Store) Close() error {
return s.db.Close()
}
// backfillDRTierFromDescriptors is the ONE-TIME legacy initialization for the v0.51.0 dr_tier
// column (called only when the ALTER just added it): a customer whose host already carries an
// ENABLED pbs_dr descriptor in desired_json gets dr_tier=1 — initialize from reality; everyone
// else stays 0 (never auto-cascade a legacy box). JSON is inspected in Go, not by LIKE, so field
// order/whitespace can't fool it.
func (s *Store) backfillDRTierFromDescriptors() error {
rows, err := s.db.Query(`SELECT customer_id, desired_json FROM hosts WHERE desired_json LIKE '%pbs_dr%'`)
if err != nil {
return err
}
defer rows.Close()
enabled := map[string]bool{}
for rows.Next() {
var customerID, desired string
if err := rows.Scan(&customerID, &desired); err != nil {
return err
}
var doc struct {
PBSDR *struct {
Enabled bool `json:"enabled"`
} `json:"pbs_dr"`
}
if json.Unmarshal([]byte(desired), &doc) == nil && doc.PBSDR != nil && doc.PBSDR.Enabled {
enabled[customerID] = true
}
}
if err := rows.Err(); err != nil {
return err
}
for customerID := range enabled {
if _, err := s.db.Exec(`UPDATE customer_configs SET dr_tier = 1 WHERE customer_id = ?`, customerID); err != nil {
return err
}
}
return nil
}
// CustomerConfig holds a pre-provisioned customer configuration.
type CustomerConfig struct {
CustomerID string
@@ -908,8 +962,14 @@ type CustomerConfig struct {
// ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report
// ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash.
ConfigVersion int
CreatedAt time.Time
UpdatedAt time.Time
// DRTier (v0.51.0, DR-tier-by-default) is the per-customer DR-tier flag — the operator INTENT
// the pbsdr cascade converges toward. Default ON for NEW customers (create handler); legacy
// rows were initialized from reality by the one-time backfill (enabled descriptor → ON).
// OFF = zero Felhom-side cost (no ep0 namespace is provisioned) and offsite provisioning is
// refused (the escrow ceremony depends on the PBS key — drill F-6, closed by policy).
DRTier bool
CreatedAt time.Time
UpdatedAt time.Time
}
// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version
@@ -921,8 +981,8 @@ type CustomerConfig struct {
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
_, err := s.db.Exec(`
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, min_controller_version, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
retrieval_password, api_key, config_json, min_controller_version, dr_tier, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
customer_name = excluded.customer_name,
domain = excluded.domain,
@@ -931,10 +991,11 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
api_key = excluded.api_key,
config_json = excluded.config_json,
min_controller_version = excluded.min_controller_version,
dr_tier = excluded.dr_tier,
config_version = customer_configs.config_version + 1,
updated_at = datetime('now')`,
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion,
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier,
)
return err
}
@@ -945,12 +1006,12 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs WHERE customer_id = ?`,
customerID,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt)
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -1000,7 +1061,7 @@ func (s *Store) ConsumeOneTimeSecret(customerID string) (string, error) {
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs ORDER BY customer_id`)
if err != nil {
return nil, err
@@ -1013,7 +1074,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
var createdAt, updatedAt string
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
return nil, err
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
@@ -1036,12 +1097,12 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
FROM customer_configs WHERE api_key = ?`,
apiKey,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.ConfigVersion, &createdAt, &updatedAt)
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}