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
+112
View File
@@ -0,0 +1,112 @@
package store
// v0.51.0 dr_tier one-time legacy backfill — "initialize from reality": on the migration that
// ADDS the column, a customer whose host already carries an ENABLED pbs_dr descriptor flips ON;
// everyone else stays OFF (never auto-cascade a legacy box). Simulated against a genuine
// pre-v0.51.0 database file (tables without dr_tier), then opened through store.New so the REAL
// migrate() path runs. Red-proof partner: make the backfill ignore `"enabled":false` vs true
// (set every pbs_dr customer ON) → the disabled-descriptor case fails.
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestDRTierBackfill_InitializesFromReality(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
// 1. Build a PRE-v0.51.0 database: customer_configs + hosts WITHOUT dr_tier.
raw, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
mustExec := func(q string, args ...any) {
t.Helper()
if _, err := raw.Exec(q, args...); err != nil {
t.Fatalf("legacy seed: %v (%s)", err, q)
}
}
mustExec(`CREATE TABLE customer_configs (
customer_id TEXT PRIMARY KEY,
customer_name TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
retrieval_password TEXT NOT NULL,
api_key TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
)`)
mustExec(`CREATE TABLE hosts (
host_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
api_key TEXT NOT NULL,
agent_version TEXT NOT NULL DEFAULT '',
last_report_at DATETIME,
desired_json TEXT NOT NULL DEFAULT '{}',
desired_generation INTEGER NOT NULL DEFAULT 0,
dr_record_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
)`)
seedCustomer := func(id string) {
mustExec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key) VALUES (?, ?, ?)`,
id, "pw", "key-"+id)
}
seedCustomer("applied") // host carries an ENABLED descriptor → must flip ON
seedCustomer("disabled") // descriptor present but enabled:false → must stay OFF
seedCustomer("plain") // no descriptor at all → must stay OFF
seedCustomer("hostless") // no host row → must stay OFF
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"applied-01", "applied", "h1",
`{"pbs_dr":{"enabled":true,"storage_id":"felhom-pbs","namespace":"applied"}}`)
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"disabled-01", "disabled", "h2",
`{"pbs_dr":{"enabled":false,"storage_id":"felhom-pbs","namespace":"disabled"}}`)
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
"plain-01", "plain", "h3", `{}`)
if err := raw.Close(); err != nil {
t.Fatal(err)
}
// 2. Open through the real store — migrate() adds dr_tier and runs the one-time backfill.
st, err := New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New over the legacy db: %v", err)
}
defer st.Close()
want := map[string]bool{"applied": true, "disabled": false, "plain": false, "hostless": false}
for id, wantOn := range want {
cfg, err := st.GetCustomerConfig(id)
if err != nil || cfg == nil {
t.Fatalf("read %s: %v", id, err)
}
if cfg.DRTier != wantOn {
t.Errorf("customer %s: dr_tier=%v, want %v (initialize from reality)", id, cfg.DRTier, wantOn)
}
}
// 3. The backfill is ONE-TIME: a later flag change must survive a re-open (the ALTER now
// fails → no re-backfill stomping operator decisions).
cfg, _ := st.GetCustomerConfig("applied")
cfg.DRTier = false // operator opts the customer out
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
st.Close()
st2, err := New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
defer st2.Close()
cfg2, _ := st2.GetCustomerConfig("applied")
if cfg2.DRTier {
t.Fatal("re-open re-ran the backfill and stomped the operator's opt-out")
}
}
+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
}