From 3adc155b14785e026a2191128775c0aef8119829 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 13:58:26 -0600 Subject: [PATCH] =?UTF-8?q?feat(modules):=20config=20loader=20refactor=20?= =?UTF-8?q?=E2=80=94=20YAML=20=E2=86=92=20DB=20=E2=86=92=20env=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ModulesConfig and ModuleToggle types to config.go for explicit module enable/disable in YAML. Add LoadState() that merges state from three sources: 1. Backward-compat YAML fields (auth.enabled, odoo.enabled) 2. Explicit modules.* YAML toggles (override compat) 3. Database module_state table (highest precedence) Validates dependency chain after loading. 5 loader tests. Ref #95 --- internal/config/config.go | 19 +++++++ internal/modules/loader.go | 84 +++++++++++++++++++++++++++++++ internal/modules/loader_test.go | 88 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 internal/modules/loader.go create mode 100644 internal/modules/loader_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 4681c80..92eb5bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,25 @@ type Config struct { Odoo OdooConfig `yaml:"odoo"` Auth AuthConfig `yaml:"auth"` Jobs JobsConfig `yaml:"jobs"` + Modules ModulesConfig `yaml:"modules"` +} + +// ModulesConfig holds explicit enable/disable toggles for optional modules. +// A nil pointer means "use the module's default state". +type ModulesConfig struct { + Auth *ModuleToggle `yaml:"auth"` + Projects *ModuleToggle `yaml:"projects"` + Audit *ModuleToggle `yaml:"audit"` + Odoo *ModuleToggle `yaml:"odoo"` + FreeCAD *ModuleToggle `yaml:"freecad"` + Jobs *ModuleToggle `yaml:"jobs"` + DAG *ModuleToggle `yaml:"dag"` +} + +// ModuleToggle holds an optional enabled flag. The pointer allows +// distinguishing "not set" (nil) from "explicitly false". +type ModuleToggle struct { + Enabled *bool `yaml:"enabled"` } // AuthConfig holds authentication and authorization settings. diff --git a/internal/modules/loader.go b/internal/modules/loader.go new file mode 100644 index 0000000..83d4bf1 --- /dev/null +++ b/internal/modules/loader.go @@ -0,0 +1,84 @@ +package modules + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kindredsystems/silo/internal/config" +) + +// LoadState applies module state from config YAML and database overrides. +// +// Precedence (highest wins): +// 1. Database module_state table +// 2. YAML modules.* toggles +// 3. Backward-compat YAML fields (auth.enabled, odoo.enabled) +// 4. Module defaults (set by NewRegistry) +func LoadState(r *Registry, cfg *config.Config, pool *pgxpool.Pool) error { + // Step 1: Apply backward-compat top-level YAML fields. + // auth.enabled and odoo.enabled existed before the modules section. + // Only apply if the new modules.* section doesn't override them. + if cfg.Modules.Auth == nil { + r.setEnabledUnchecked(Auth, cfg.Auth.Enabled) + } + if cfg.Modules.Odoo == nil { + r.setEnabledUnchecked(Odoo, cfg.Odoo.Enabled) + } + + // Step 2: Apply explicit modules.* YAML toggles (override defaults + compat). + applyToggle(r, Auth, cfg.Modules.Auth) + applyToggle(r, Projects, cfg.Modules.Projects) + applyToggle(r, Audit, cfg.Modules.Audit) + applyToggle(r, Odoo, cfg.Modules.Odoo) + applyToggle(r, FreeCAD, cfg.Modules.FreeCAD) + applyToggle(r, Jobs, cfg.Modules.Jobs) + applyToggle(r, DAG, cfg.Modules.DAG) + + // Step 3: Apply database overrides (highest precedence). + if pool != nil { + if err := loadFromDB(r, pool); err != nil { + return err + } + } + + // Step 4: Validate the final state. + return r.ValidateDependencies() +} + +// applyToggle sets a module's state from a YAML ModuleToggle if present. +func applyToggle(r *Registry, id string, toggle *config.ModuleToggle) { + if toggle == nil || toggle.Enabled == nil { + return + } + r.setEnabledUnchecked(id, *toggle.Enabled) +} + +// setEnabledUnchecked sets module state without dependency validation. +// Used during loading when the full state is being assembled incrementally. +func (r *Registry) setEnabledUnchecked(id string, enabled bool) { + r.mu.Lock() + defer r.mu.Unlock() + if m, ok := r.modules[id]; ok && !m.Required { + m.enabled = enabled + } +} + +// loadFromDB reads module_state rows and applies them to the registry. +func loadFromDB(r *Registry, pool *pgxpool.Pool) error { + rows, err := pool.Query(context.Background(), + `SELECT module_id, enabled FROM module_state`) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id string + var enabled bool + if err := rows.Scan(&id, &enabled); err != nil { + return err + } + r.setEnabledUnchecked(id, enabled) + } + return rows.Err() +} diff --git a/internal/modules/loader_test.go b/internal/modules/loader_test.go new file mode 100644 index 0000000..28bd161 --- /dev/null +++ b/internal/modules/loader_test.go @@ -0,0 +1,88 @@ +package modules + +import ( + "testing" + + "github.com/kindredsystems/silo/internal/config" +) + +func boolPtr(v bool) *bool { return &v } + +func TestLoadState_DefaultsOnly(t *testing.T) { + r := NewRegistry() + cfg := &config.Config{} + + if err := LoadState(r, cfg, nil); err != nil { + t.Fatalf("LoadState: %v", err) + } + + // Auth defaults to true from registry, but cfg.Auth.Enabled is false + // (zero value) and backward-compat applies, so auth ends up disabled. + if r.IsEnabled(Auth) { + t.Error("auth should be disabled (cfg.Auth.Enabled is false by default)") + } +} + +func TestLoadState_BackwardCompat(t *testing.T) { + r := NewRegistry() + cfg := &config.Config{} + cfg.Auth.Enabled = true + cfg.Odoo.Enabled = true + + if err := LoadState(r, cfg, nil); err != nil { + t.Fatalf("LoadState: %v", err) + } + + if !r.IsEnabled(Auth) { + t.Error("auth should be enabled via cfg.Auth.Enabled") + } + if !r.IsEnabled(Odoo) { + t.Error("odoo should be enabled via cfg.Odoo.Enabled") + } +} + +func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) { + r := NewRegistry() + cfg := &config.Config{} + cfg.Auth.Enabled = true // compat says enabled + cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled + + if err := LoadState(r, cfg, nil); err != nil { + t.Fatalf("LoadState: %v", err) + } + + if r.IsEnabled(Auth) { + t.Error("modules.auth.enabled=false should override auth.enabled=true") + } +} + +func TestLoadState_EnableJobsAndDAG(t *testing.T) { + r := NewRegistry() + cfg := &config.Config{} + cfg.Auth.Enabled = true + cfg.Modules.Jobs = &config.ModuleToggle{Enabled: boolPtr(true)} + cfg.Modules.DAG = &config.ModuleToggle{Enabled: boolPtr(true)} + + if err := LoadState(r, cfg, nil); err != nil { + t.Fatalf("LoadState: %v", err) + } + + if !r.IsEnabled(Jobs) { + t.Error("jobs should be enabled") + } + if !r.IsEnabled(DAG) { + t.Error("dag should be enabled") + } +} + +func TestLoadState_InvalidDependency(t *testing.T) { + r := NewRegistry() + cfg := &config.Config{} + // Auth disabled (default), but enable jobs which depends on auth. + cfg.Modules.Jobs = &config.ModuleToggle{Enabled: boolPtr(true)} + + err := LoadState(r, cfg, nil) + if err == nil { + t.Error("should fail: jobs enabled but auth disabled") + } +}