hub v0.15.0: Phase 2 managed updates — per-customer controller-version floor
Operator sets a minimum controller version (FLOOR), per-customer defaulting to a
global floor; the report ACK returns the effective floor + latest_version so the
controller auto-updates to the floor when below it (latest stays the opt-in button).
- store: min_controller_version column + hub_settings global floor + Effective/
Get/SetGlobal/SetMin resolution + config/env DEFAULT_MIN_CONTROLLER_VERSION
- handler: report ACK {min_controller_version, latest_version}; LatestVersionProvider
- web: global floor editor + per-customer override form + Floor column (English)
- tests: floor resolution + ACK + render; override-precedence red-proof verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSZmmSFVzGwEzhYmxbkgBK
This commit is contained in:
+91
-11
@@ -15,6 +15,17 @@ import (
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
logger *log.Logger
|
||||
|
||||
// defaultMinControllerVersion is the config/env-supplied global FLOOR fallback (Phase 2 managed
|
||||
// updates). Used only when neither a per-customer override nor a hub_settings row is set.
|
||||
defaultMinControllerVersion string
|
||||
}
|
||||
|
||||
// SetDefaultMinControllerVersion sets the config/env-supplied global floor fallback. Called once at
|
||||
// startup from main (DEFAULT_MIN_CONTROLLER_VERSION). A hub_settings row, when present, takes
|
||||
// precedence over this value (see GetGlobalMinControllerVersion).
|
||||
func (s *Store) SetDefaultMinControllerVersion(v string) {
|
||||
s.defaultMinControllerVersion = v
|
||||
}
|
||||
|
||||
// CustomerSummary holds the latest status for a customer (for dashboard).
|
||||
@@ -116,6 +127,25 @@ func (s *Store) migrate() error {
|
||||
// v0.2.1: add status column to customer_configs (idempotent)
|
||||
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")
|
||||
|
||||
// v0.15.0: per-customer minimum controller version (the managed-update FLOOR). Empty = no
|
||||
// per-customer override → the effective floor falls back to the global default (hub_settings /
|
||||
// config). Idempotent.
|
||||
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_version TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
// 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.
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hub_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// v0.3.0: events table for hub-native monitoring
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
@@ -685,16 +715,19 @@ type CustomerConfig struct {
|
||||
APIKey string
|
||||
ConfigJSON string // JSON object with customer-specific override fields
|
||||
Status string // "active" or "blocked"
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// MinControllerVersion is the per-customer minimum controller version (managed-update FLOOR
|
||||
// override). Empty = use the global default. Set/cleared via the operator UI.
|
||||
MinControllerVersion string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveCustomerConfig creates or updates a customer configuration.
|
||||
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, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
retrieval_password, api_key, config_json, min_controller_version, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(customer_id) DO UPDATE SET
|
||||
customer_name = excluded.customer_name,
|
||||
domain = excluded.domain,
|
||||
@@ -702,9 +735,10 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
|
||||
retrieval_password = excluded.retrieval_password,
|
||||
api_key = excluded.api_key,
|
||||
config_json = excluded.config_json,
|
||||
min_controller_version = excluded.min_controller_version,
|
||||
updated_at = datetime('now')`,
|
||||
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
|
||||
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON,
|
||||
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -715,11 +749,11 @@ 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, created_at, updated_at
|
||||
retrieval_password, api_key, config_json, status, min_controller_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.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||
&createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -736,7 +770,7 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, 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, created_at, updated_at
|
||||
retrieval_password, api_key, config_json, status, min_controller_version, created_at, updated_at
|
||||
FROM customer_configs ORDER BY customer_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -748,7 +782,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
|
||||
var cfg CustomerConfig
|
||||
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.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -772,11 +806,11 @@ 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, created_at, updated_at
|
||||
retrieval_password, api_key, config_json, status, min_controller_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.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||
&createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -799,6 +833,52 @@ func (s *Store) SetCustomerConfigStatus(customerID, status string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMinControllerVersion sets (or clears, with "") the per-customer controller-version floor
|
||||
// override. The customer config must already exist.
|
||||
func (s *Store) SetMinControllerVersion(customerID, version string) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE customer_configs SET min_controller_version = ?, updated_at = datetime('now')
|
||||
WHERE customer_id = ?`,
|
||||
version, customerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetGlobalMinControllerVersion returns the operator-set global floor from hub_settings if present,
|
||||
// else the config/env-supplied default. Empty string = no global floor.
|
||||
func (s *Store) GetGlobalMinControllerVersion() string {
|
||||
var v string
|
||||
err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = 'min_controller_version'`).Scan(&v)
|
||||
if err == nil && v != "" {
|
||||
return v
|
||||
}
|
||||
// No DB override (missing row or empty value) → fall back to the config/env default.
|
||||
return s.defaultMinControllerVersion
|
||||
}
|
||||
|
||||
// SetGlobalMinControllerVersion persists the operator-set global floor (overriding the config/env
|
||||
// default). Pass "" to clear the override and fall back to the default.
|
||||
func (s *Store) SetGlobalMinControllerVersion(version string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hub_settings (key, value, updated_at)
|
||||
VALUES ('min_controller_version', ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
version,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// EffectiveMinControllerVersion resolves the floor that actually applies to a customer: the
|
||||
// per-customer override when set (non-empty), otherwise the global floor (hub_settings → config/env
|
||||
// default). Returns "" when no floor applies at all (Phase 2 inert for that customer).
|
||||
func (s *Store) EffectiveMinControllerVersion(customerID string) string {
|
||||
cfg, err := s.GetCustomerConfig(customerID)
|
||||
if err == nil && cfg != nil && cfg.MinControllerVersion != "" {
|
||||
return cfg.MinControllerVersion
|
||||
}
|
||||
return s.GetGlobalMinControllerVersion()
|
||||
}
|
||||
|
||||
// IsCustomerBlocked returns true if the customer config has status "blocked".
|
||||
func (s *Store) IsCustomerBlocked(customerID string) bool {
|
||||
var status string
|
||||
|
||||
Reference in New Issue
Block a user