hub v0.26.0: pull-based config delivery + retire inbound GUI controls

config_version counter (bumped on every config save) advertised in the report
ACK; controller re-pulls + self-restarts on a change. Retire Trigger Update /
Push Config / Pull Config / Show Diff handlers+routes+buttons and the inbound
geo-notify (keep hub->Cloudflare geo removal). Setup command -> host-install;
delete dead customer.html + config_detail.html. Closes AUDIT-hub-gui F-S1/F-S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
2026-06-30 21:49:33 +02:00
parent e51e03bd7b
commit a3ac6c9488
10 changed files with 226 additions and 1247 deletions
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"io"
"log"
"path/filepath"
"testing"
)
// SaveCustomerConfig bumps config_version on every save: a new row seeds at 1, each subsequent save
// increments. This counter is the signal the controller's pull-based config-refresh keys off.
func TestSaveCustomerConfig_BumpsConfigVersion(t *testing.T) {
s, err := New(filepath.Join(t.TempDir(), "cv.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { s.Close() })
save := func(json string) {
t.Helper()
if err := s.SaveCustomerConfig(&CustomerConfig{
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: json,
}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
}
get := func() int {
t.Helper()
cfg, err := s.GetCustomerConfig("c")
if err != nil || cfg == nil {
t.Fatalf("GetCustomerConfig: %v (cfg=%v)", err, cfg)
}
return cfg.ConfigVersion
}
save("{}")
if v := get(); v != 1 {
t.Fatalf("after create config_version = %d, want 1", v)
}
save(`{"git":{"username":"a"}}`)
if v := get(); v != 2 {
t.Fatalf("after first edit config_version = %d, want 2", v)
}
save(`{"git":{"username":"b"}}`)
if v := get(); v != 3 {
t.Fatalf("after second edit config_version = %d, want 3", v)
}
// A second, independent customer starts its own counter at 1 (not affected by c's bumps).
if err := s.SaveCustomerConfig(&CustomerConfig{
CustomerID: "d", RetrievalPassword: "pw", APIKey: "k2", ConfigJSON: "{}",
}); err != nil {
t.Fatalf("SaveCustomerConfig(d): %v", err)
}
cfgD, _ := s.GetCustomerConfig("d")
if cfgD.ConfigVersion != 1 {
t.Errorf("new customer d config_version = %d, want 1 (per-customer counter)", cfgD.ConfigVersion)
}
}
+28 -11
View File
@@ -132,6 +132,14 @@ func (s *Store) migrate() error {
// config). Idempotent.
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_version TEXT NOT NULL DEFAULT ''")
// v0.26.0: per-customer config_version — a monotonic counter bumped on every config save. The
// report ACK advertises it; the controller compares it against its last-applied version and, on a
// change, re-pulls controller.yaml + self-restarts (pull-based config delivery — no inbound). It is
// a STORED COUNTER, never a hash of the rendered YAML (configgen emits a fresh session_secret +
// timestamp every call, so a content hash would change spuriously). Idempotent. Existing rows seed
// 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.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.
@@ -718,16 +726,24 @@ type CustomerConfig struct {
// 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
// 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
}
// SaveCustomerConfig creates or updates a customer configuration.
// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version
// (new rows start at 1; updates increment) — this is the load-bearing signal that drives the
// controller's pull-based config-refresh via the report ACK. Bumping here covers every field that
// feeds the generated controller.yaml (identity + the config_json overrides). NOTE: the floor
// (min_controller_version), block/unblock status, and retrieval-password regen are deliberately NOT
// config.yaml content and intentionally do NOT bump it (they have their own signals or none).
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, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
retrieval_password, api_key, config_json, min_controller_version, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
customer_name = excluded.customer_name,
domain = excluded.domain,
@@ -736,6 +752,7 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
api_key = excluded.api_key,
config_json = excluded.config_json,
min_controller_version = excluded.min_controller_version,
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,
@@ -749,12 +766,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, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, 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,
&createdAt, &updatedAt)
&cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -770,7 +787,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, min_controller_version, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
FROM customer_configs ORDER BY customer_id`)
if err != nil {
return nil, err
@@ -783,7 +800,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,
&createdAt, &updatedAt); err != nil {
&cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
return nil, err
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
@@ -806,12 +823,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, created_at, updated_at
retrieval_password, api_key, config_json, status, min_controller_version, 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,
&createdAt, &updatedAt)
&cfg.ConfigVersion, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}