From f91cf2bc6fb991d93fd7550dbfe00ac7a884c0de Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 13:56:26 -0600 Subject: [PATCH 1/7] feat(modules): settings_overrides and module_state migration Add migration 016 with two tables for the module system: - settings_overrides: dotted-path config overrides set via admin UI - module_state: per-module enabled/disabled state Update testutil.TruncateAll to include new tables. Ref #94 --- internal/testutil/testutil.go | 1 + migrations/016_module_system.sql | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 migrations/016_module_system.sql diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 58a0e0f..f00d5a5 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -80,6 +80,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) { _, err := pool.Exec(context.Background(), ` TRUNCATE + settings_overrides, module_state, job_log, jobs, job_definitions, runners, dag_cross_edges, dag_edges, dag_nodes, audit_log, sync_log, api_tokens, sessions, item_files, diff --git a/migrations/016_module_system.sql b/migrations/016_module_system.sql new file mode 100644 index 0000000..52aa47b --- /dev/null +++ b/migrations/016_module_system.sql @@ -0,0 +1,15 @@ +-- 016_module_system.sql — settings overrides and module state persistence + +CREATE TABLE IF NOT EXISTS settings_overrides ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_by TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS module_state ( + module_id TEXT PRIMARY KEY, + enabled BOOLEAN NOT NULL, + updated_by TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); From 9d8afa598148d71a545013f0e9365f825e853b66 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 13:57:32 -0600 Subject: [PATCH 2/7] feat(modules): module registry with metadata, dependencies, and defaults In-memory registry for 10 modules (3 required, 7 optional). SetEnabled validates dependency chains: cannot enable a module whose dependencies are disabled, cannot disable a module that others depend on. 9 unit tests covering default state, toggling, dependency validation, and error cases. Ref #96 --- internal/modules/modules.go | 163 +++++++++++++++++++++++++++++ internal/modules/modules_test.go | 169 +++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 internal/modules/modules.go create mode 100644 internal/modules/modules_test.go diff --git a/internal/modules/modules.go b/internal/modules/modules.go new file mode 100644 index 0000000..c0fb3e0 --- /dev/null +++ b/internal/modules/modules.go @@ -0,0 +1,163 @@ +// Package modules provides the module registry for Silo. +// Each module groups API endpoints, UI views, and configuration. +// Modules can be required (always on) or optional (admin-toggleable). +package modules + +import ( + "fmt" + "sort" + "sync" +) + +// Module IDs. +const ( + Core = "core" + Schemas = "schemas" + Storage = "storage" + Auth = "auth" + Projects = "projects" + Audit = "audit" + Odoo = "odoo" + FreeCAD = "freecad" + Jobs = "jobs" + DAG = "dag" +) + +// ModuleInfo describes a module's metadata. +type ModuleInfo struct { + ID string + Name string + Description string + Required bool // cannot be disabled + DefaultEnabled bool // initial state for optional modules + DependsOn []string // module IDs this module requires + Version string +} + +// registry entries with their runtime enabled state. +type moduleState struct { + ModuleInfo + enabled bool +} + +// Registry holds all module definitions and their enabled state. +type Registry struct { + mu sync.RWMutex + modules map[string]*moduleState +} + +// builtinModules defines the complete set of Silo modules. +var builtinModules = []ModuleInfo{ + {ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"}, + {ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true}, + {ID: Storage, Name: "Storage", Description: "MinIO/S3 file storage, presigned uploads", Required: true}, + {ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true}, + {ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true}, + {ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true}, + {ID: Odoo, Name: "Odoo ERP", Description: "Odoo integration (config, sync-log, push/pull)", DependsOn: []string{Auth}}, + {ID: FreeCAD, Name: "Create Integration", Description: "URI scheme, executable path, client settings", DefaultEnabled: true}, + {ID: Jobs, Name: "Job Queue", Description: "Async compute jobs, runner management", DependsOn: []string{Auth}}, + {ID: DAG, Name: "Dependency DAG", Description: "Feature DAG sync, validation states, interference detection", DependsOn: []string{Jobs}}, +} + +// NewRegistry creates a registry with all builtin modules set to their default state. +func NewRegistry() *Registry { + r := &Registry{modules: make(map[string]*moduleState, len(builtinModules))} + for _, m := range builtinModules { + enabled := m.Required || m.DefaultEnabled + r.modules[m.ID] = &moduleState{ModuleInfo: m, enabled: enabled} + } + return r +} + +// IsEnabled returns whether a module is currently enabled. +func (r *Registry) IsEnabled(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + if m, ok := r.modules[id]; ok { + return m.enabled + } + return false +} + +// SetEnabled changes a module's enabled state with dependency validation. +func (r *Registry) SetEnabled(id string, enabled bool) error { + r.mu.Lock() + defer r.mu.Unlock() + + m, ok := r.modules[id] + if !ok { + return fmt.Errorf("unknown module %q", id) + } + + if m.Required { + return fmt.Errorf("module %q is required and cannot be disabled", id) + } + + if enabled { + // Check that all dependencies are enabled. + for _, dep := range m.DependsOn { + if dm, ok := r.modules[dep]; ok && !dm.enabled { + return fmt.Errorf("cannot enable %q: dependency %q is disabled", id, dep) + } + } + } else { + // Check that no enabled module depends on this one. + for _, other := range r.modules { + if !other.enabled || other.ID == id { + continue + } + for _, dep := range other.DependsOn { + if dep == id { + return fmt.Errorf("cannot disable %q: module %q depends on it", id, other.ID) + } + } + } + } + + m.enabled = enabled + return nil +} + +// All returns info for every module, sorted by ID. +func (r *Registry) All() []ModuleInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]ModuleInfo, 0, len(r.modules)) + for _, m := range r.modules { + out = append(out, m.ModuleInfo) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// Get returns info for a single module, or nil if not found. +func (r *Registry) Get(id string) *ModuleInfo { + r.mu.RLock() + defer r.mu.RUnlock() + if m, ok := r.modules[id]; ok { + info := m.ModuleInfo + return &info + } + return nil +} + +// ValidateDependencies checks that every enabled module's dependencies +// are also enabled. Returns the first violation found. +func (r *Registry) ValidateDependencies() error { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, m := range r.modules { + if !m.enabled { + continue + } + for _, dep := range m.DependsOn { + if dm, ok := r.modules[dep]; ok && !dm.enabled { + return fmt.Errorf("module %q is enabled but its dependency %q is disabled", m.ID, dep) + } + } + } + return nil +} diff --git a/internal/modules/modules_test.go b/internal/modules/modules_test.go new file mode 100644 index 0000000..1747591 --- /dev/null +++ b/internal/modules/modules_test.go @@ -0,0 +1,169 @@ +package modules + +import ( + "testing" +) + +func TestNewRegistry_DefaultState(t *testing.T) { + r := NewRegistry() + + // Required modules are always enabled. + for _, id := range []string{Core, Schemas, Storage} { + if !r.IsEnabled(id) { + t.Errorf("required module %q should be enabled by default", id) + } + } + + // Optional modules with DefaultEnabled=true. + for _, id := range []string{Auth, Projects, Audit, FreeCAD} { + if !r.IsEnabled(id) { + t.Errorf("module %q should be enabled by default", id) + } + } + + // Optional modules with DefaultEnabled=false. + for _, id := range []string{Odoo, Jobs, DAG} { + if r.IsEnabled(id) { + t.Errorf("module %q should be disabled by default", id) + } + } +} + +func TestSetEnabled_BasicToggle(t *testing.T) { + r := NewRegistry() + + // Disable an optional module with no dependents. + if err := r.SetEnabled(Projects, false); err != nil { + t.Fatalf("disabling projects: %v", err) + } + if r.IsEnabled(Projects) { + t.Error("projects should be disabled after SetEnabled(false)") + } + + // Re-enable it. + if err := r.SetEnabled(Projects, true); err != nil { + t.Fatalf("enabling projects: %v", err) + } + if !r.IsEnabled(Projects) { + t.Error("projects should be enabled after SetEnabled(true)") + } +} + +func TestCannotDisableRequired(t *testing.T) { + r := NewRegistry() + + for _, id := range []string{Core, Schemas, Storage} { + if err := r.SetEnabled(id, false); err == nil { + t.Errorf("disabling required module %q should return error", id) + } + } +} + +func TestDependencyChain_EnableWithoutDep(t *testing.T) { + r := NewRegistry() + + // Jobs depends on Auth. Auth is enabled by default, so enabling jobs works. + if err := r.SetEnabled(Jobs, true); err != nil { + t.Fatalf("enabling jobs (auth enabled): %v", err) + } + + // DAG depends on Jobs. Jobs is now enabled, so enabling dag works. + if err := r.SetEnabled(DAG, true); err != nil { + t.Fatalf("enabling dag (jobs enabled): %v", err) + } + + // Now try with deps disabled. Start fresh. + r2 := NewRegistry() + + // DAG depends on Jobs, which is disabled by default. + if err := r2.SetEnabled(DAG, true); err == nil { + t.Error("enabling dag without jobs should fail") + } +} + +func TestDisableDependedOn(t *testing.T) { + r := NewRegistry() + + // Enable the full chain: auth (already on) → jobs → dag. + if err := r.SetEnabled(Jobs, true); err != nil { + t.Fatal(err) + } + if err := r.SetEnabled(DAG, true); err != nil { + t.Fatal(err) + } + + // Cannot disable jobs while dag depends on it. + if err := r.SetEnabled(Jobs, false); err == nil { + t.Error("disabling jobs while dag is enabled should fail") + } + + // Disable dag first, then jobs should work. + if err := r.SetEnabled(DAG, false); err != nil { + t.Fatal(err) + } + if err := r.SetEnabled(Jobs, false); err != nil { + t.Fatalf("disabling jobs after dag disabled: %v", err) + } +} + +func TestCannotDisableAuthWhileJobsEnabled(t *testing.T) { + r := NewRegistry() + + if err := r.SetEnabled(Jobs, true); err != nil { + t.Fatal(err) + } + + // Auth is depended on by jobs. + if err := r.SetEnabled(Auth, false); err == nil { + t.Error("disabling auth while jobs is enabled should fail") + } +} + +func TestUnknownModule(t *testing.T) { + r := NewRegistry() + + if r.IsEnabled("nonexistent") { + t.Error("unknown module should not be enabled") + } + if err := r.SetEnabled("nonexistent", true); err == nil { + t.Error("setting unknown module should return error") + } + if r.Get("nonexistent") != nil { + t.Error("getting unknown module should return nil") + } +} + +func TestAll_ReturnsAllModules(t *testing.T) { + r := NewRegistry() + all := r.All() + + if len(all) != 10 { + t.Errorf("expected 10 modules, got %d", len(all)) + } + + // Should be sorted by ID. + for i := 1; i < len(all); i++ { + if all[i].ID < all[i-1].ID { + t.Errorf("modules not sorted: %s before %s", all[i-1].ID, all[i].ID) + } + } +} + +func TestValidateDependencies(t *testing.T) { + r := NewRegistry() + + // Default state should be valid. + if err := r.ValidateDependencies(); err != nil { + t.Fatalf("default state should be valid: %v", err) + } + + // Force an invalid state by directly mutating (bypassing SetEnabled). + r.mu.Lock() + r.modules[Jobs].enabled = true + r.modules[Auth].enabled = false + r.mu.Unlock() + + if err := r.ValidateDependencies(); err == nil { + t.Error("should detect jobs enabled without auth") + } +} From 3adc155b14785e026a2191128775c0aef8119829 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 13:58:26 -0600 Subject: [PATCH 3/7] =?UTF-8?q?feat(modules):=20config=20loader=20refactor?= =?UTF-8?q?=20=E2=80=94=20YAML=20=E2=86=92=20DB=20=E2=86=92=20env=20pipeli?= =?UTF-8?q?ne?= 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") + } +} From 4fd40133601e924ba4a58857b041751062eeca7b Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 14:00:24 -0600 Subject: [PATCH 4/7] feat(modules): wire registry into server startup Add modules.Registry and config.Config fields to Server struct. Create registry in main.go, load state from YAML+DB, log all module states at startup. Conditionally start job/runner sweeper goroutines only when the jobs module is enabled. Update all 5 test files to pass registry to NewServer. Ref #95, #96 --- cmd/silod/main.go | 48 +++++++++++++++++++----------- internal/api/auth_handlers_test.go | 7 +++-- internal/api/bom_handlers_test.go | 7 +++-- internal/api/csv_handlers_test.go | 7 +++-- internal/api/dag_handlers_test.go | 2 ++ internal/api/handlers.go | 7 +++++ internal/api/job_handlers_test.go | 2 ++ 7 files changed, 57 insertions(+), 23 deletions(-) diff --git a/cmd/silod/main.go b/cmd/silod/main.go index 7530160..28f00a8 100644 --- a/cmd/silod/main.go +++ b/cmd/silod/main.go @@ -20,6 +20,7 @@ import ( "github.com/kindredsystems/silo/internal/config" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/jobdef" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/storage" "github.com/rs/zerolog" @@ -219,6 +220,16 @@ func main() { } } + // Initialize module registry + registry := modules.NewRegistry() + if err := modules.LoadState(registry, cfg, database.Pool()); err != nil { + logger.Fatal().Err(err).Msg("failed to load module state") + } + for _, m := range registry.All() { + logger.Info().Str("module", m.ID).Bool("enabled", registry.IsEnabled(m.ID)). + Bool("required", m.Required).Msg("module") + } + // Create SSE broker and server state broker := api.NewBroker(logger) serverState := api.NewServerState(logger, store, broker) @@ -232,27 +243,30 @@ func main() { // Create API server server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store, authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState, - jobDefs, cfg.Jobs.Directory) + jobDefs, cfg.Jobs.Directory, registry, cfg) router := api.NewRouter(server, logger) - // Start background sweepers for job/runner timeouts - go func() { - ticker := time.NewTicker(time.Duration(cfg.Jobs.JobTimeoutCheck) * time.Second) - defer ticker.Stop() - for range ticker.C { - if n, err := jobRepo.TimeoutExpiredJobs(ctx); err != nil { - logger.Error().Err(err).Msg("job timeout sweep failed") - } else if n > 0 { - logger.Info().Int64("count", n).Msg("timed out expired jobs") - } + // Start background sweepers for job/runner timeouts (only when jobs module enabled) + if registry.IsEnabled(modules.Jobs) { + go func() { + ticker := time.NewTicker(time.Duration(cfg.Jobs.JobTimeoutCheck) * time.Second) + defer ticker.Stop() + for range ticker.C { + if n, err := jobRepo.TimeoutExpiredJobs(ctx); err != nil { + logger.Error().Err(err).Msg("job timeout sweep failed") + } else if n > 0 { + logger.Info().Int64("count", n).Msg("timed out expired jobs") + } - if n, err := jobRepo.ExpireStaleRunners(ctx, time.Duration(cfg.Jobs.RunnerTimeout)*time.Second); err != nil { - logger.Error().Err(err).Msg("runner expiry sweep failed") - } else if n > 0 { - logger.Info().Int64("count", n).Msg("expired stale runners") + if n, err := jobRepo.ExpireStaleRunners(ctx, time.Duration(cfg.Jobs.RunnerTimeout)*time.Second); err != nil { + logger.Error().Err(err).Msg("runner expiry sweep failed") + } else if n > 0 { + logger.Info().Int64("count", n).Msg("expired stale runners") + } } - } - }() + }() + logger.Info().Msg("job/runner sweepers started") + } // Create HTTP server addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) diff --git a/internal/api/auth_handlers_test.go b/internal/api/auth_handlers_test.go index 85e3a17..d16769a 100644 --- a/internal/api/auth_handlers_test.go +++ b/internal/api/auth_handlers_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" @@ -38,8 +39,10 @@ func newAuthTestServer(t *testing.T) *Server { nil, // authConfig broker, state, - nil, // jobDefs - "", // jobDefsDir + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + nil, // cfg ) } diff --git a/internal/api/bom_handlers_test.go b/internal/api/bom_handlers_test.go index d63ca31..4b890f4 100644 --- a/internal/api/bom_handlers_test.go +++ b/internal/api/bom_handlers_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" @@ -35,8 +36,10 @@ func newTestServer(t *testing.T) *Server { nil, // authConfig (nil = dev mode) broker, state, - nil, // jobDefs - "", // jobDefsDir + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + nil, // cfg ) } diff --git a/internal/api/csv_handlers_test.go b/internal/api/csv_handlers_test.go index 85edb10..bc194a7 100644 --- a/internal/api/csv_handlers_test.go +++ b/internal/api/csv_handlers_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" @@ -64,8 +65,10 @@ func newTestServerWithSchemas(t *testing.T) *Server { nil, // authConfig broker, state, - nil, // jobDefs - "", // jobDefsDir + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + nil, // cfg ) } diff --git a/internal/api/dag_handlers_test.go b/internal/api/dag_handlers_test.go index 15cdae4..f987054 100644 --- a/internal/api/dag_handlers_test.go +++ b/internal/api/dag_handlers_test.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" @@ -29,6 +30,7 @@ func newDAGTestServer(t *testing.T) *Server { nil, nil, nil, nil, nil, broker, state, nil, "", + modules.NewRegistry(), nil, ) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3b3493f..3278953 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -19,6 +19,7 @@ import ( "github.com/kindredsystems/silo/internal/config" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/jobdef" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/partnum" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/storage" @@ -48,6 +49,8 @@ type Server struct { jobs *db.JobRepository jobDefs map[string]*jobdef.Definition jobDefsDir string + modules *modules.Registry + cfg *config.Config } // NewServer creates a new API server. @@ -65,6 +68,8 @@ func NewServer( state *ServerState, jobDefs map[string]*jobdef.Definition, jobDefsDir string, + registry *modules.Registry, + cfg *config.Config, ) *Server { items := db.NewItemRepository(database) projects := db.NewProjectRepository(database) @@ -96,6 +101,8 @@ func NewServer( jobs: jobs, jobDefs: jobDefs, jobDefsDir: jobDefsDir, + modules: registry, + cfg: cfg, } } diff --git a/internal/api/job_handlers_test.go b/internal/api/job_handlers_test.go index a26be57..b9899f1 100644 --- a/internal/api/job_handlers_test.go +++ b/internal/api/job_handlers_test.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/testutil" "github.com/rs/zerolog" @@ -29,6 +30,7 @@ func newJobTestServer(t *testing.T) *Server { nil, nil, nil, nil, nil, broker, state, nil, "", + modules.NewRegistry(), nil, ) } From b8abd8859dc0f61a57a350e7bb7768ee82f582d9 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 14:01:32 -0600 Subject: [PATCH 5/7] feat(modules): RequireModule middleware to gate route groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RequireModule middleware that returns 404 with {"error":"module '' is not enabled"} when a module is disabled. Wrap route groups: - projects → RequireModule("projects") - audit → RequireModule("audit") - integrations/odoo → RequireModule("odoo") - jobs, job-definitions, runners → RequireModule("jobs") - /api/runner (runner-facing) → RequireModule("jobs") - dag → RequireModule("dag") (extracted into sub-route) Ref #98 --- internal/api/middleware.go | 16 ++++++++++++++++ internal/api/routes.go | 27 +++++++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 74362c0..cdaaeb4 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -183,6 +183,22 @@ func (s *Server) RequireRunnerAuth(next http.Handler) http.Handler { }) } +// RequireModule returns middleware that rejects requests with 404 when +// the named module is not enabled. +func (s *Server) RequireModule(id string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.modules.IsEnabled(id) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"module '` + id + `' is not enabled"}`)) + return + } + next.ServeHTTP(w, r) + }) + } +} + func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { diff --git a/internal/api/routes.go b/internal/api/routes.go index 3aa4181..49c6fc7 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -101,6 +101,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Projects (read: viewer, write: editor) r.Route("/projects", func(r chi.Router) { + r.Use(server.RequireModule("projects")) r.Get("/", server.HandleListProjects) r.Get("/{code}", server.HandleGetProject) r.Get("/{code}/items", server.HandleGetProjectItems) @@ -150,10 +151,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) - // DAG (read: viewer, write: editor) - r.Get("/dag", server.HandleGetDAG) - r.Get("/dag/forward-cone/{nodeKey}", server.HandleGetForwardCone) - r.Get("/dag/dirty", server.HandleGetDirtySubgraph) + // DAG (gated by dag module) + r.Route("/dag", func(r chi.Router) { + r.Use(server.RequireModule("dag")) + r.Get("/", server.HandleGetDAG) + r.Get("/forward-cone/{nodeKey}", server.HandleGetForwardCone) + r.Get("/dirty", server.HandleGetDirtySubgraph) + + r.Group(func(r chi.Router) { + r.Use(server.RequireWritable) + r.Use(server.RequireRole(auth.RoleEditor)) + r.Put("/", server.HandleSyncDAG) + r.Post("/mark-dirty/{nodeKey}", server.HandleMarkDirty) + }) + }) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) @@ -174,20 +185,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Post("/bom/merge", server.HandleMergeBOM) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) - r.Put("/dag", server.HandleSyncDAG) - r.Post("/dag/mark-dirty/{nodeKey}", server.HandleMarkDirty) }) }) }) // Audit (read-only, viewer role) r.Route("/audit", func(r chi.Router) { + r.Use(server.RequireModule("audit")) r.Get("/completeness", server.HandleAuditCompleteness) r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail) }) // Integrations (read: viewer, write: editor) r.Route("/integrations/odoo", func(r chi.Router) { + r.Use(server.RequireModule("odoo")) r.Get("/config", server.HandleGetOdooConfig) r.Get("/sync-log", server.HandleGetOdooSyncLog) @@ -210,6 +221,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Jobs (read: viewer, write: editor) r.Route("/jobs", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobs) r.Get("/{jobID}", server.HandleGetJob) r.Get("/{jobID}/logs", server.HandleGetJobLogs) @@ -224,6 +236,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Job definitions (read: viewer, reload: admin) r.Route("/job-definitions", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobDefinitions) r.Get("/{name}", server.HandleGetJobDefinition) @@ -235,6 +248,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Runners (admin) r.Route("/runners", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Use(server.RequireRole(auth.RoleAdmin)) r.Get("/", server.HandleListRunners) r.Post("/", server.HandleRegisterRunner) @@ -251,6 +265,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Runner-facing API (runner token auth, not user auth) r.Route("/api/runner", func(r chi.Router) { + r.Use(server.RequireModule("jobs")) r.Use(server.RequireRunnerAuth) r.Post("/heartbeat", server.HandleRunnerHeartbeat) r.Post("/claim", server.HandleRunnerClaim) From 690ad73161b0f52d2d02073f4566c6b0079bb946 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 14:02:11 -0600 Subject: [PATCH 6/7] feat(modules): public GET /api/modules discovery endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HandleGetModules returning module state, metadata, and public config (auth providers, Create URI scheme). No auth required — clients call this pre-login. Register at /api/modules before the auth middleware. Ref #97 --- internal/api/handlers.go | 48 ++++++++++++++++++++++++++++++++++++++++ internal/api/routes.go | 1 + 2 files changed, 49 insertions(+) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3278953..b5afdb1 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -173,6 +173,54 @@ func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) { }) } +// HandleGetModules returns the public module discovery response. +// No authentication required — clients call this pre-login. +func (s *Server) HandleGetModules(w http.ResponseWriter, r *http.Request) { + mods := make(map[string]any, 10) + for _, m := range s.modules.All() { + entry := map[string]any{ + "enabled": s.modules.IsEnabled(m.ID), + "required": m.Required, + "name": m.Name, + } + if m.Version != "" { + entry["version"] = m.Version + } + if len(m.DependsOn) > 0 { + entry["depends_on"] = m.DependsOn + } + + // Public config (non-secret) for specific modules. + switch m.ID { + case "auth": + if s.cfg != nil { + entry["config"] = map[string]any{ + "local_enabled": s.cfg.Auth.Local.Enabled, + "ldap_enabled": s.cfg.Auth.LDAP.Enabled, + "oidc_enabled": s.cfg.Auth.OIDC.Enabled, + "oidc_issuer_url": s.cfg.Auth.OIDC.IssuerURL, + } + } + case "freecad": + if s.cfg != nil { + entry["config"] = map[string]any{ + "uri_scheme": s.cfg.FreeCAD.URIScheme, + } + } + } + + mods[m.ID] = entry + } + + writeJSON(w, http.StatusOK, map[string]any{ + "modules": mods, + "server": map[string]any{ + "version": "0.2", + "read_only": s.serverState.IsReadOnly(), + }, + }) +} + // Schema handlers // SchemaResponse represents a schema in API responses. diff --git a/internal/api/routes.go b/internal/api/routes.go index 49c6fc7..9829c6c 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -58,6 +58,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/auth/callback", server.HandleOIDCCallback) // Public API endpoints (no auth required) + r.Get("/api/modules", server.HandleGetModules) r.Get("/api/auth/config", server.HandleAuthConfig) // API routes (require auth, no CSRF — token auth instead) From 138ce160109a0178b308510702326fe03ca9ed84 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 14:02:48 -0600 Subject: [PATCH 7/7] fix: remove unreachable code in testutil.findProjectRoot --- internal/testutil/testutil.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index f00d5a5..35b99ae 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -3,7 +3,6 @@ package testutil import ( "context" - "fmt" "os" "path/filepath" "sort" @@ -112,6 +111,4 @@ func findProjectRoot(t *testing.T) string { } dir = parent } - - panic(fmt.Sprintf("unreachable")) }