main #111
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
84
internal/modules/loader.go
Normal file
84
internal/modules/loader.go
Normal 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()
|
||||
}
|
||||
88
internal/modules/loader_test.go
Normal file
88
internal/modules/loader_test.go
Normal 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
163
internal/modules/modules.go
Normal 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
|
||||
}
|
||||
169
internal/modules/modules_test.go
Normal file
169
internal/modules/modules_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
15
migrations/016_module_system.sql
Normal file
15
migrations/016_module_system.sql
Normal 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()
|
||||
);
|
||||
Reference in New Issue
Block a user