Merge pull request 'feat: module system — registry, middleware, and discovery endpoint' (#102) from feat-module-system into main

Reviewed-on: #102
This commit was merged in pull request #102.
This commit is contained in:
2026-02-14 20:05:42 +00:00
16 changed files with 682 additions and 32 deletions

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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,
)
}

View File

@@ -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,
}
}
@@ -166,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.

View File

@@ -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,
)
}

View File

@@ -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 ") {

View File

@@ -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)
@@ -101,6 +102,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 +152,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 +186,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 +222,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 +237,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 +249,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 +266,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)

View File

@@ -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.

View File

@@ -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()
}

View File

@@ -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")
}
}

163
internal/modules/modules.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -3,7 +3,6 @@ package testutil
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
@@ -80,6 +79,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,
@@ -111,6 +111,4 @@ func findProjectRoot(t *testing.T) string {
}
dir = parent
}
panic(fmt.Sprintf("unreachable"))
}

View File

@@ -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()
);