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