diff --git a/.gitignore b/.gitignore index 19bc47e8f..36bd0a4a0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,6 @@ node_modules/ # Built admin UI assets (generated by `make build-ui`, embedded via go:embed) module/ui_dist/* -!module/ui_dist/.gitkeep*.db-shm +!module/ui_dist/.gitkeep +*.db-shm *.db-wal diff --git a/admin/config.yaml b/admin/config.yaml index afaa2fbf3..3f64d3bea 100644 --- a/admin/config.yaml +++ b/admin/config.yaml @@ -112,6 +112,16 @@ modules: - name: admin-metrics type: metrics.collector + # --- Feature Flags --- + # Provides the feature-flag admin service; auto-discovered by the V1 API handler. + - name: admin-feature-flags + type: featureflag.service + config: + provider: "generic" + cache_ttl: "30s" + sse_enabled: true + db_path: "data/featureflags.db" + workflows: http-admin: router: admin-router diff --git a/cmd/server/main.go b/cmd/server/main.go index 475c7be0f..2a1b949ed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -198,55 +198,114 @@ func loadConfig(logger *slog.Logger) (*config.WorkflowConfig, error) { return config.NewEmptyWorkflowConfig(), nil } +// --------------------------------------------------------------------------- +// Minimal interfaces — only the methods actually called by the server are +// listed here. Holding interfaces instead of concrete pointers keeps +// serverApp decoupled from every module implementation. +// --------------------------------------------------------------------------- + +// v1StoreIface is the minimal interface over *module.V1Store used by the server. +type v1StoreIface interface { + GetSystemWorkflow() (*module.V1Workflow, error) + ResetSystemWorkflow(configYAML string) error + EnsureSystemHierarchy(ownerID, adminConfigYAML string) (string, string, string, string, error) + DB() *sql.DB + CreateWorkflow(projectID, name, slug, description, configYAML, createdBy string) (*module.V1Workflow, error) + Close() error +} + +// ioCloser wraps any store that only needs to be closed on shutdown. +type ioCloser interface { + Close() error +} + +// closableEventStore is an EventStore that can also be closed on shutdown. +type closableEventStore interface { + evstore.EventStore + Close() error +} + +// pipelineEventSetter is the subset of *handlers.PipelineWorkflowHandler +// called after the engine starts. +type pipelineEventSetter interface { + SetEventRecorder(r module.EventRecorder) +} + +// executionTrackerIface is the minimal interface over *module.ExecutionTracker. +type executionTrackerIface interface { + module.ExecutionTrackerProvider + SetEventStoreRecorder(r module.EventRecorder) +} + +// runtimeLifecycle manages the lifecycle of running workflow instances. +type runtimeLifecycle interface { + StopAll(ctx context.Context) error + LoadFromPaths(ctx context.Context, paths []string) error + LaunchFromWorkspace(ctx context.Context, id, name, yamlContent, workspaceDir string) error +} + +// observabilityReporter is implemented by the background observability reporter. +type observabilityReporter interface { + Stop() +} + +// --------------------------------------------------------------------------- +// Domain-scoped sub-structs grouping related serverApp fields +// --------------------------------------------------------------------------- + +// storeComponents holds all persistent data stores opened at startup. +type storeComponents struct { + v1Store v1StoreIface // v1 API workflow data store + eventStore closableEventStore // execution event store + idempotencyDB *sql.DB // idempotency store DB connection + envStore ioCloser // environment management store +} + +// mgmtComponents holds management HTTP service handlers created at startup +// that survive engine reloads. +type mgmtComponents struct { + auditLogger *audit.Logger + mgmtHandler http.Handler // engine config/reload/status + schemaSvc http.Handler // module schema browsing + combinedAI http.Handler // AI generation + deploy + dynHandler http.Handler // dynamic components API +} + +// serviceComponents holds post-start service handlers and the pipeline/ +// execution subsystem. These are registered with each new Application +// instance after an engine reload. +type serviceComponents struct { + v1Handler http.Handler // V1 API handler (dashboard) + pipelineHandler pipelineEventSetter // pipeline execution handler + executionTracker executionTrackerIface // CQRS execution tracking + runtimeManager runtimeLifecycle // filesystem-loaded workflow instances + reporter observabilityReporter // background observability reporter + timelineMux http.Handler // timeline handler mux + replayMux http.Handler // replay handler mux + backfillMux http.Handler // backfill/mock/diff handler mux + dlqMux http.Handler // DLQ handler mux + billingMux http.Handler // billing handler mux + nativeHandler http.Handler // native plugin handler + envMux http.Handler // environment management mux + cloudMux http.Handler // cloud providers mux + pluginRegMux http.Handler // plugin registry mux + runtimeMux http.Handler // runtime instances API + ingestMux http.Handler // ingest API for remote workers +} + // serverApp holds all components needed to run the server. Persistent resources // (stores, handlers, muxes) are stored here so they survive engine reloads. // Only the engine itself (modules, handlers, triggers) is recreated on reload. type serverApp struct { - engine *workflow.StdEngine - engineManager *workflow.WorkflowEngineManager - pipelineHandler *handlers.PipelineWorkflowHandler - logger *slog.Logger - auditLogger *audit.Logger - cleanupDirs []string // temp directories to clean up on shutdown - cleanupFiles []string // temp files to clean up on shutdown - postStartFuncs []func() error // functions to run after engine.Start - - // ----------------------------------------------------------------------- - // Persistent stores — opened once, survive reloads - // ----------------------------------------------------------------------- - v1Store *module.V1Store // v1 API SQLite store - eventStore *evstore.SQLiteEventStore // execution event store - idempotencyDB *sql.DB // idempotency store DB connection - envStore *environment.SQLiteStore // environment management store - - // ----------------------------------------------------------------------- - // Management service handlers — created once at startup, survive reloads. - // These are http.Handlers or service objects that get registered with each - // new Application instance after engine reload. - // ----------------------------------------------------------------------- - - // Pre-start services (registered before engine.Start) - mgmtHandler *module.WorkflowUIHandler // engine config/reload/status - schemaSvc *schema.SchemaService // module schema browsing - combinedAI http.Handler // AI generation + deploy - dynHandler http.Handler // dynamic components API - - // Post-start services (registered after engine.Start, need V1Store) - v1Handler *module.V1APIHandler // V1 API handler (dashboard) - timelineMux http.Handler // timeline handler mux - replayMux http.Handler // replay handler mux - backfillMux http.Handler // backfill/mock/diff handler mux - dlqMux http.Handler // DLQ handler mux - billingMux http.Handler // billing handler mux - nativeHandler http.Handler // native plugin handler - envMux http.Handler // environment management mux - cloudMux http.Handler // cloud providers mux - pluginRegMux http.Handler // plugin registry mux - executionTracker *module.ExecutionTracker // execution tracking for CQRS - runtimeManager *module.RuntimeManager // filesystem-loaded workflow instances - runtimeMux http.Handler // runtime instances API - ingestMux http.Handler // ingest API for remote workers - reporter *observability.Reporter // reporter for sending data to admin (when worker) + engine *workflow.StdEngine + engineManager *workflow.WorkflowEngineManager + logger *slog.Logger + cleanupDirs []string // temp directories to clean up on shutdown + cleanupFiles []string // temp files to clean up on shutdown + postStartFuncs []func() error // functions to run after engine.Start + stores storeComponents + mgmt mgmtComponents + services serviceComponents } // setup initializes all server components: engine, AI services, and HTTP mux. @@ -267,7 +326,7 @@ func setup(logger *slog.Logger, cfg *config.WorkflowConfig) (*serverApp, error) return nil, fmt.Errorf("failed to build engine: %w", err) } app.engine = engine - app.pipelineHandler = pipelineHandler + app.services.pipelineHandler = pipelineHandler // Initialize AI services and dynamic component pool pool := dynamic.NewInterpreterPool() @@ -290,8 +349,8 @@ func setup(logger *slog.Logger, cfg *config.WorkflowConfig) (*serverApp, error) }) // Initialize audit logger (writes structured JSON to stdout) - app.auditLogger = audit.NewLogger(os.Stdout) - app.auditLogger.LogConfigChange(context.Background(), "system", "server", "server started") + app.mgmt.auditLogger = audit.NewLogger(os.Stdout) + app.mgmt.auditLogger.LogConfigChange(context.Background(), "system", "server", "server started") return app, nil } @@ -362,20 +421,20 @@ func initManagementHandlers(logger *slog.Logger, engine *workflow.StdEngine, cfg return map[string]any{"status": "running"} }) mgmtHandler.SetServiceRegistry(func() map[string]any { - return app.engine.App().SvcRegistry() + return app.engine.GetApp().SvcRegistry() }) - app.mgmtHandler = mgmtHandler + app.mgmt.mgmtHandler = mgmtHandler // AI handlers (combined into a single http.Handler) aiH := ai.NewHandler(aiSvc) deployH := ai.NewDeployHandler(deploySvc) - app.combinedAI = ai.NewCombinedHandler(aiH, deployH) + app.mgmt.combinedAI = ai.NewCombinedHandler(aiH, deployH) // Dynamic components handler - app.dynHandler = dynamic.NewAPIHandler(loader, registry) + app.mgmt.dynHandler = dynamic.NewAPIHandler(loader, registry) // Schema handler - app.schemaSvc = schema.NewSchemaService() + app.mgmt.schemaSvc = schema.NewSchemaService() } // registerManagementServices registers the pre-start management service handlers @@ -387,10 +446,10 @@ func registerManagementServices(logger *slog.Logger, app *serverApp) { // Register service modules — these are resolved by delegate config in admin/config.yaml svcModules := map[string]any{ - "admin-engine-mgmt": app.mgmtHandler, - "admin-schema-mgmt": app.schemaSvc, - "admin-ai-mgmt": app.combinedAI, - "admin-component-mgmt": app.dynHandler, + "admin-engine-mgmt": app.mgmt.mgmtHandler, + "admin-schema-mgmt": app.mgmt.schemaSvc, + "admin-ai-mgmt": app.mgmt.combinedAI, + "admin-component-mgmt": app.mgmt.dynHandler, } for name, svc := range svcModules { engine.GetApp().RegisterModule(module.NewServiceModule(name, svc)) @@ -456,7 +515,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { } logger.Info("Opened standalone v1 data store (no WorkflowRegistry found)", "path", dbPath) } - app.v1Store = store + app.stores.v1Store = store // If --restore-admin, reset the system workflow to the embedded default if *restoreAdmin { @@ -493,27 +552,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { } return app.reloadEngine(cfg) }) - app.v1Handler = v1Handler - - // ----------------------------------------------------------------------- - // Feature Flag service — wire into the V1 API handler so the admin UI - // can manage flags via /api/v1/admin/feature-flags. - // ----------------------------------------------------------------------- - - ffDBPath := filepath.Join(*dataDir, "featureflags.db") - ffMod, ffErr := module.NewFeatureFlagModule("admin-feature-flags", module.FeatureFlagModuleConfig{ - Provider: "generic", - CacheTTL: "30s", - SSEEnabled: true, - DBPath: ffDBPath, - }) - if ffErr != nil { - logger.Warn("Failed to create feature flag module — feature flags disabled", "error", ffErr) - } else { - ffAdapter := module.NewFeatureFlagAdminAdapter(ffMod.Service(), ffMod.Store()) - v1Handler.SetFeatureFlagService(ffAdapter) - logger.Info("Wired feature flag service to V1 API handler", "db", ffDBPath) - } + app.services.v1Handler = v1Handler // ----------------------------------------------------------------------- // Event store, idempotency store @@ -525,7 +564,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { if err != nil { logger.Warn("Failed to create event store — timeline/replay/diff features disabled", "error", err) } else { - app.eventStore = eventStore + app.stores.eventStore = eventStore logger.Info("Opened event store", "path", eventsDBPath) } @@ -537,7 +576,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { logger.Warn("Failed to open idempotency DB", "error", err) } else { idempotencyDB.SetMaxOpenConns(1) - app.idempotencyDB = idempotencyDB + app.stores.idempotencyDB = idempotencyDB idempotencyStore, idErr := evstore.NewSQLiteIdempotencyStore(idempotencyDB) if idErr != nil { logger.Warn("Failed to create idempotency store", "error", idErr) @@ -556,13 +595,13 @@ func (app *serverApp) initStores(logger *slog.Logger) error { timelineHandler := evstore.NewTimelineHandler(eventStore, logger) timelineMux := http.NewServeMux() timelineHandler.RegisterRoutes(timelineMux) - app.timelineMux = timelineMux + app.services.timelineMux = timelineMux // Replay handler replayHandler := evstore.NewReplayHandler(eventStore, logger) replayMux := http.NewServeMux() replayHandler.RegisterRoutes(replayMux) - app.replayMux = replayMux + app.services.replayMux = replayMux // Backfill / Mock / Diff handler backfillStore := evstore.NewInMemoryBackfillStore() @@ -571,15 +610,15 @@ func (app *serverApp) initStores(logger *slog.Logger) error { bmdHandler := evstore.NewBackfillMockDiffHandler(backfillStore, mockStore, diffCalc, logger) bmdMux := http.NewServeMux() bmdHandler.RegisterRoutes(bmdMux) - app.backfillMux = bmdMux + app.services.backfillMux = bmdMux logger.Info("Created timeline, replay, and backfill/mock/diff handlers") } else { // Create stub handlers so delegate routes return 503 instead of 500 stubMsg := "event store unavailable — timeline/replay/backfill features disabled" - app.timelineMux = featureDisabledHandler(stubMsg) - app.replayMux = featureDisabledHandler(stubMsg) - app.backfillMux = featureDisabledHandler(stubMsg) + app.services.timelineMux = featureDisabledHandler(stubMsg) + app.services.replayMux = featureDisabledHandler(stubMsg) + app.services.backfillMux = featureDisabledHandler(stubMsg) logger.Info("Created stub handlers for timeline/replay/backfill (event store unavailable)") } @@ -591,7 +630,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { dlqHandler := evstore.NewDLQHandler(dlqStore, logger) dlqMux := http.NewServeMux() dlqHandler.RegisterRoutes(dlqMux) - app.dlqMux = dlqMux + app.services.dlqMux = dlqMux // ----------------------------------------------------------------------- // Billing handler @@ -602,7 +641,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { billingHandler := billing.NewHandler(billingMeter, billingProvider) billingMux := http.NewServeMux() billingHandler.RegisterRoutes(billingMux) - app.billingMux = billingMux + app.services.billingMux = billingMux logger.Info("Created DLQ and billing handlers") @@ -669,7 +708,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { cp.RegisterRoutes(cloudMux) } } - app.cloudMux = cloudMux + app.services.cloudMux = cloudMux // Enable all registered plugins so their routes are active allPlugins := pluginMgr.AllPlugins() @@ -689,11 +728,11 @@ func (app *serverApp) initStores(logger *slog.Logger) error { cp.RegisterRoutes(cloudMux) } } - app.cloudMux = cloudMux + app.services.cloudMux = cloudMux // Plugin discovery + route handler (backed by PluginManager) nativeHandler := plugin.NewNativeHandler(pluginMgr) - app.nativeHandler = nativeHandler + app.services.nativeHandler = nativeHandler logger.Info("Registered native plugins", "count", len(pluginMgr.AllPlugins())) @@ -705,13 +744,13 @@ func (app *serverApp) initStores(logger *slog.Logger) error { envStore, envErr := environment.NewSQLiteStore(envDBPath) if envErr != nil { logger.Warn("Failed to create environment store — environment management disabled", "error", envErr) - app.envMux = featureDisabledHandler("environment store unavailable — environment management disabled") + app.services.envMux = featureDisabledHandler("environment store unavailable — environment management disabled") } else { - app.envStore = envStore + app.stores.envStore = envStore envHandler := environment.NewHandler(envStore) envMux := http.NewServeMux() envHandler.RegisterRoutes(envMux) - app.envMux = envMux + app.services.envMux = envMux logger.Info("Registered environment management service", "path", envDBPath) } @@ -741,7 +780,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { pluginHandler := plugin.NewRegistryHandler(compositeReg) pluginMux := http.NewServeMux() pluginHandler.RegisterRoutes(pluginMux) - app.pluginRegMux = pluginMux + app.services.pluginRegMux = pluginMux logger.Info("Registered plugin composite registry (local + remote)") @@ -753,7 +792,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { if sysWf, sysErr := store.GetSystemWorkflow(); sysErr == nil && sysWf != nil { workflowID = sysWf.ID } - app.executionTracker = &module.ExecutionTracker{ + app.services.executionTracker = &module.ExecutionTracker{ Store: store, WorkflowID: workflowID, Tracer: tracing.NewWorkflowTracer(nil), // uses global OTEL provider @@ -767,7 +806,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { ingestHandler := observability.NewIngestHandler(ingestStore, logger) ingestMux := http.NewServeMux() ingestHandler.RegisterRoutes(ingestMux) - app.ingestMux = ingestMux + app.services.ingestMux = ingestMux logger.Info("Registered ingest handler for remote worker observability") // ----------------------------------------------------------------------- @@ -775,7 +814,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { // ----------------------------------------------------------------------- if reporter := observability.ReporterFromEnv(logger); reporter != nil { - app.reporter = reporter + app.services.reporter = reporter reporter.Start(context.Background()) logger.Info("Started observability reporter", "admin_url", os.Getenv("WORKFLOW_ADMIN_URL")) } @@ -799,7 +838,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { } rm := module.NewRuntimeManager(store, runtimeBuilder, logger) - app.runtimeManager = rm + app.services.runtimeManager = rm v1Handler.SetRuntimeManager(rm) // Wire up port allocator for auto-port assignment on deployed workflows. @@ -811,7 +850,7 @@ func (app *serverApp) initStores(logger *slog.Logger) error { // Create runtime handler runtimeHandler := module.NewRuntimeHandler(rm) - app.runtimeMux = runtimeHandler + app.services.runtimeMux = runtimeHandler if *loadWorkflows != "" { // Parse comma-separated paths @@ -837,33 +876,33 @@ func (app *serverApp) registerPostStartServices(logger *slog.Logger) error { // Wire EventRecorder adapter to the pipeline handler so pipeline // executions emit events to the event store. - if app.eventStore != nil && app.pipelineHandler != nil { - recorder := evstore.NewEventRecorderAdapter(app.eventStore) - app.pipelineHandler.SetEventRecorder(recorder) + if app.stores.eventStore != nil && app.services.pipelineHandler != nil { + recorder := evstore.NewEventRecorderAdapter(app.stores.eventStore) + app.services.pipelineHandler.SetEventRecorder(recorder) logger.Info("Wired EventRecorder to PipelineWorkflowHandler") } // Register V1 handler - if app.v1Handler != nil { - engine.GetApp().RegisterModule(module.NewServiceModule("admin-v1-mgmt", app.v1Handler)) - if err := engine.GetApp().RegisterService("admin-v1-mgmt", app.v1Handler); err != nil { + if app.services.v1Handler != nil { + engine.GetApp().RegisterModule(module.NewServiceModule("admin-v1-mgmt", app.services.v1Handler)) + if err := engine.GetApp().RegisterService("admin-v1-mgmt", app.services.v1Handler); err != nil { logger.Warn("Failed to register v1 service", "error", err) } } // Register all delegate service modules with the new Application delegateServices := map[string]http.Handler{ - "admin-timeline-mgmt": app.timelineMux, - "admin-replay-mgmt": app.replayMux, - "admin-backfill-mgmt": app.backfillMux, - "admin-dlq-mgmt": app.dlqMux, - "admin-billing-mgmt": app.billingMux, - "admin-native-plugins": app.nativeHandler, - "admin-env-mgmt": app.envMux, - "admin-cloud-providers": app.cloudMux, - "admin-plugin-registry": app.pluginRegMux, - "admin-ingest-mgmt": app.ingestMux, - "admin-runtime-mgmt": app.runtimeMux, + "admin-timeline-mgmt": app.services.timelineMux, + "admin-replay-mgmt": app.services.replayMux, + "admin-backfill-mgmt": app.services.backfillMux, + "admin-dlq-mgmt": app.services.dlqMux, + "admin-billing-mgmt": app.services.billingMux, + "admin-native-plugins": app.services.nativeHandler, + "admin-env-mgmt": app.services.envMux, + "admin-cloud-providers": app.services.cloudMux, + "admin-plugin-registry": app.services.pluginRegMux, + "admin-ingest-mgmt": app.services.ingestMux, + "admin-runtime-mgmt": app.services.runtimeMux, } for name, handler := range delegateServices { if handler == nil { @@ -875,24 +914,45 @@ func (app *serverApp) registerPostStartServices(logger *slog.Logger) error { } } + // Auto-discover FeatureFlagAdmin from the service registry and wire to the + // V1 API handler. The featureflag.service module (admin-feature-flags in + // admin/config.yaml) registers its admin adapter under a well-known name. + type featureFlagSetter interface { + SetFeatureFlagService(module.FeatureFlagAdmin) + } + const featureFlagAdminSvcName = "admin-feature-flags.admin" + if ffSetter, ok := app.services.v1Handler.(featureFlagSetter); ok { + svcRegistry := engine.GetApp().SvcRegistry() + if svc, ok := svcRegistry[featureFlagAdminSvcName]; ok { + if ffAdmin, ok := svc.(module.FeatureFlagAdmin); ok { + ffSetter.SetFeatureFlagService(ffAdmin) + logger.Info("Auto-wired FeatureFlagAdmin to V1 API handler from service registry", "service", featureFlagAdminSvcName) + } else { + logger.Warn("Service registered under feature flag admin name does not implement FeatureFlagAdmin", "service", featureFlagAdminSvcName) + } + } else { + logger.Debug("FeatureFlagAdmin service not found in service registry; feature flags disabled", "service", featureFlagAdminSvcName) + } + } + // Wire execution tracking on CQRS handlers - if app.executionTracker != nil { + if app.services.executionTracker != nil { // Also wire the event store recorder so CQRS pipelines emit events // to the event store (used by store browser and timeline features). - if app.eventStore != nil { - app.executionTracker.EventStoreRecorder = evstore.NewEventRecorderAdapter(app.eventStore) + if app.stores.eventStore != nil { + app.services.executionTracker.SetEventStoreRecorder(evstore.NewEventRecorderAdapter(app.stores.eventStore)) logger.Info("Wired EventStoreRecorder to execution tracker") } for _, svc := range engine.GetApp().SvcRegistry() { switch h := svc.(type) { case *module.QueryHandler: - h.SetExecutionTracker(app.executionTracker) + h.SetExecutionTracker(app.services.executionTracker) case *module.CommandHandler: - h.SetExecutionTracker(app.executionTracker) + h.SetExecutionTracker(app.services.executionTracker) } } - logger.Info("Wired execution tracker to CQRS handlers", "workflow_id", app.executionTracker.WorkflowID) + logger.Info("Wired execution tracker to CQRS handlers") } // Resolve delegates that couldn't be resolved during Init @@ -929,7 +989,7 @@ func (app *serverApp) reloadEngine(newCfg *config.WorkflowConfig) error { // Update the serverApp references BEFORE registering services, // since registerManagementServices reads app.engine. app.engine = newEngine - app.pipelineHandler = newPipelineHandler + app.services.pipelineHandler = newPipelineHandler // Re-register pre-start management services with the new Application registerManagementServices(logger, app) @@ -941,7 +1001,7 @@ func (app *serverApp) reloadEngine(newCfg *config.WorkflowConfig) error { // Re-register post-start services (stores already initialized, just need // to be re-registered with the new Application's service registry). - if app.v1Store != nil { + if app.stores.v1Store != nil { if regErr := app.registerPostStartServices(logger); regErr != nil { return fmt.Errorf("failed to re-register post-start services: %w", regErr) } @@ -999,19 +1059,19 @@ func (app *serverApp) importBundles(logger *slog.Logger) error { } // Create a workflow record in the V1Store - if app.v1Store != nil { - sysWf, sysErr := app.v1Store.GetSystemWorkflow() + if app.stores.v1Store != nil { + sysWf, sysErr := app.stores.v1Store.GetSystemWorkflow() projectID := "" if sysErr == nil && sysWf != nil { projectID = sysWf.ProjectID } - wf, createErr := app.v1Store.CreateWorkflow(projectID, name, "", manifest.Description, yamlContent, "system") + wf, createErr := app.stores.v1Store.CreateWorkflow(projectID, name, "", manifest.Description, yamlContent, "system") if createErr != nil { logger.Error("Failed to create workflow record", "name", name, "error", createErr) } else { // Update workspace dir on the record - _, _ = app.v1Store.DB().Exec( + _, _ = app.stores.v1Store.DB().Exec( `UPDATE workflows SET workspace_dir = ? WHERE id = ?`, destDir, wf.ID, ) @@ -1020,8 +1080,8 @@ func (app *serverApp) importBundles(logger *slog.Logger) error { } // Deploy via RuntimeManager - if app.runtimeManager != nil { - if launchErr := app.runtimeManager.LaunchFromWorkspace(context.Background(), id, name, yamlContent, destDir); launchErr != nil { + if app.services.runtimeManager != nil { + if launchErr := app.services.runtimeManager.LaunchFromWorkspace(context.Background(), id, name, yamlContent, destDir); launchErr != nil { logger.Error("Failed to launch bundle workflow", "name", name, "error", launchErr) continue } @@ -1058,13 +1118,13 @@ func run(ctx context.Context, app *serverApp, listenAddr string) error { <-ctx.Done() // Stop observability reporter (final flush) - if app.reporter != nil { - app.reporter.Stop() + if app.services.reporter != nil { + app.services.reporter.Stop() app.logger.Info("Stopped observability reporter") } - if app.runtimeManager != nil { - if err := app.runtimeManager.StopAll(context.Background()); err != nil { + if app.services.runtimeManager != nil { + if err := app.services.runtimeManager.StopAll(context.Background()); err != nil { app.logger.Error("Runtime manager shutdown error", "error", err) } } @@ -1080,29 +1140,29 @@ func run(ctx context.Context, app *serverApp, listenAddr string) error { } // Close v1 store - if app.v1Store != nil { - if err := app.v1Store.Close(); err != nil { + if app.stores.v1Store != nil { + if err := app.stores.v1Store.Close(); err != nil { app.logger.Error("V1 store close error", "error", err) } } // Close event store - if app.eventStore != nil { - if err := app.eventStore.Close(); err != nil { + if app.stores.eventStore != nil { + if err := app.stores.eventStore.Close(); err != nil { app.logger.Error("Event store close error", "error", err) } } // Close idempotency DB - if app.idempotencyDB != nil { - if err := app.idempotencyDB.Close(); err != nil { + if app.stores.idempotencyDB != nil { + if err := app.stores.idempotencyDB.Close(); err != nil { app.logger.Error("Idempotency DB close error", "error", err) } } // Close environment store - if app.envStore != nil { - if err := app.envStore.Close(); err != nil { + if app.stores.envStore != nil { + if err := app.stores.envStore.Close(); err != nil { app.logger.Error("Environment store close error", "error", err) } } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 185cbb732..45e0ce129 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -635,9 +635,13 @@ triggers: {} // Construct a minimal serverApp with the required fields app := &serverApp{ - logger: logger, - v1Store: store, - runtimeManager: rm, + logger: logger, + stores: storeComponents{ + v1Store: store, + }, + services: serviceComponents{ + runtimeManager: rm, + }, } // Run the import @@ -753,9 +757,13 @@ func TestImportBundles_MultipleBundles(t *testing.T) { rm := module.NewRuntimeManager(store, mockBuilder, logger) app := &serverApp{ - logger: logger, - v1Store: store, - runtimeManager: rm, + logger: logger, + stores: storeComponents{ + v1Store: store, + }, + services: serviceComponents{ + runtimeManager: rm, + }, } if err := app.importBundles(logger); err != nil { @@ -777,6 +785,114 @@ func TestImportBundles_MultipleBundles(t *testing.T) { } } +// mockFeatureFlagAdmin implements module.FeatureFlagAdmin for testing. +type mockFeatureFlagAdmin struct{} + +func (m *mockFeatureFlagAdmin) ListFlags() ([]any, error) { return nil, nil } +func (m *mockFeatureFlagAdmin) GetFlag(key string) (any, error) { return nil, nil } +func (m *mockFeatureFlagAdmin) CreateFlag(data json.RawMessage) (any, error) { return nil, nil } +func (m *mockFeatureFlagAdmin) UpdateFlag(key string, data json.RawMessage) (any, error) { + return nil, nil +} +func (m *mockFeatureFlagAdmin) DeleteFlag(key string) error { return nil } +func (m *mockFeatureFlagAdmin) SetOverrides(key string, data json.RawMessage) (any, error) { + return nil, nil +} +func (m *mockFeatureFlagAdmin) EvaluateFlag(key string, user string, group string) (any, error) { + return nil, nil +} +func (m *mockFeatureFlagAdmin) SSEHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}) } + +// TestFeatureFlagAutoWiring verifies that registerPostStartServices wires a +// FeatureFlagAdmin from the service registry into the V1 API handler. +// When the service is present, the feature-flags route returns 401 (auth required) +// instead of 503 (service unavailable). When absent, it returns 503. +func TestFeatureFlagAutoWiring_WiredWhenServicePresent(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + store, err := module.OpenV1Store(dbPath) + if err != nil { + t.Fatalf("OpenV1Store: %v", err) + } + t.Cleanup(func() { store.Close() }) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + engine, _, _, _, err := buildEngine(config.NewEmptyWorkflowConfig(), logger) + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + + v1Handler := module.NewV1APIHandler(store, "test-secret-key-at-least-32-chars!!") + + // Register the mock FeatureFlagAdmin under the well-known service name. + const ffAdminSvc = "admin-feature-flags.admin" + if regErr := engine.GetApp().RegisterService(ffAdminSvc, module.FeatureFlagAdmin(&mockFeatureFlagAdmin{})); regErr != nil { + t.Fatalf("RegisterService: %v", regErr) + } + + app := &serverApp{ + engine: engine, + logger: logger, + services: serviceComponents{ + v1Handler: v1Handler, + }, + } + if err := app.registerPostStartServices(logger); err != nil { + t.Fatalf("registerPostStartServices: %v", err) + } + + // With FeatureFlagAdmin wired, the route should require auth (401), not return 503. + req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil) + w := httptest.NewRecorder() + v1Handler.HandleV1(w, req) + if w.Code == http.StatusServiceUnavailable { + t.Errorf("expected feature flags to be wired (not 503), got %d", w.Code) + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 (auth required), got %d", w.Code) + } +} + +// TestFeatureFlagAutoWiring_NotWiredWhenServiceAbsent verifies that when no +// FeatureFlagAdmin is registered, the feature-flags route returns 503. +func TestFeatureFlagAutoWiring_NotWiredWhenServiceAbsent(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + store, err := module.OpenV1Store(dbPath) + if err != nil { + t.Fatalf("OpenV1Store: %v", err) + } + t.Cleanup(func() { store.Close() }) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + engine, _, _, _, err := buildEngine(config.NewEmptyWorkflowConfig(), logger) + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + + v1Handler := module.NewV1APIHandler(store, "test-secret-key-at-least-32-chars!!") + + // Do NOT register the FeatureFlagAdmin service. + app := &serverApp{ + engine: engine, + logger: logger, + services: serviceComponents{ + v1Handler: v1Handler, + }, + } + if err := app.registerPostStartServices(logger); err != nil { + t.Fatalf("registerPostStartServices: %v", err) + } + + // Without FeatureFlagAdmin, the route should return 503. + req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil) + w := httptest.NewRecorder() + v1Handler.HandleV1(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503 (service unavailable), got %d", w.Code) + } +} + func TestEnvOverride_ImportBundle(t *testing.T) { origImportBundle := *importBundle t.Cleanup(func() { *importBundle = origImportBundle }) diff --git a/handlers/integration.go b/handlers/integration.go index 72032611f..468cf2224 100644 --- a/handlers/integration.go +++ b/handlers/integration.go @@ -139,7 +139,7 @@ func configureHTTPConnector(name string, config map[string]any) (module.Integrat } if allowPrivate, ok := config["allowPrivateIPs"].(bool); ok && allowPrivate { - httpConn.SetAllowPrivateIPs(true) + httpConn.AllowPrivateIPs() } return httpConn, nil diff --git a/handlers/integration_handler_test.go b/handlers/integration_handler_test.go index 681a470a5..2c9f61206 100644 --- a/handlers/integration_handler_test.go +++ b/handlers/integration_handler_test.go @@ -96,7 +96,7 @@ func TestIntegrationWorkflowHandler_ExecuteIntegrationWorkflow(t *testing.T) { h := NewIntegrationWorkflowHandler() registry := module.NewIntegrationRegistry("test-registry") conn := module.NewHTTPIntegrationConnector("test-api", server.URL) - conn.SetAllowPrivateIPs(true) + conn.AllowPrivateIPs() _ = conn.Connect(context.Background()) registry.RegisterConnector(conn) @@ -148,7 +148,7 @@ func TestIntegrationWorkflowHandler_ExecuteIntegrationWorkflow_NotConnected(t *t h := NewIntegrationWorkflowHandler() registry := module.NewIntegrationRegistry("test-registry") conn := module.NewHTTPIntegrationConnector("test-api", server.URL) - conn.SetAllowPrivateIPs(true) + conn.AllowPrivateIPs() // Not connecting - should auto-connect registry.RegisterConnector(conn) @@ -178,7 +178,7 @@ func TestIntegrationWorkflowHandler_ExecuteIntegrationWorkflow_VariableSubstitut h := NewIntegrationWorkflowHandler() registry := module.NewIntegrationRegistry("test-registry") conn := module.NewHTTPIntegrationConnector("test-api", server.URL) - conn.SetAllowPrivateIPs(true) + conn.AllowPrivateIPs() _ = conn.Connect(context.Background()) registry.RegisterConnector(conn) @@ -216,7 +216,7 @@ func TestIntegrationWorkflowHandler_ExecuteIntegrationWorkflow_WithOnError(t *te h := NewIntegrationWorkflowHandler() registry := module.NewIntegrationRegistry("test-registry") conn := module.NewHTTPIntegrationConnector("test-api", server.URL) - conn.SetAllowPrivateIPs(true) + conn.AllowPrivateIPs() _ = conn.Connect(context.Background()) registry.RegisterConnector(conn) @@ -247,7 +247,7 @@ func TestIntegrationWorkflowHandler_ExecuteIntegrationWorkflow_WithOnSuccess(t * h := NewIntegrationWorkflowHandler() registry := module.NewIntegrationRegistry("test-registry") conn := module.NewHTTPIntegrationConnector("test-api", server.URL) - conn.SetAllowPrivateIPs(true) + conn.AllowPrivateIPs() _ = conn.Connect(context.Background()) registry.RegisterConnector(conn) diff --git a/handlers/integration_test.go b/handlers/integration_test.go index 155662758..1a4ae07dd 100644 --- a/handlers/integration_test.go +++ b/handlers/integration_test.go @@ -81,9 +81,9 @@ func TestIntegrationWorkflow(t *testing.T) { // Pre-create and connect the HTTP connectors with the mock server URL crmConnector := module.NewHTTPIntegrationConnector("crm-connector", mockServer.URL) - crmConnector.SetAllowPrivateIPs(true) + crmConnector.AllowPrivateIPs() emailConnector := module.NewHTTPIntegrationConnector("email-connector", mockServer.URL) - emailConnector.SetAllowPrivateIPs(true) + emailConnector.AllowPrivateIPs() // Set default headers crmConnector.SetDefaultHeader("Content-Type", "application/json") diff --git a/module/execution_tracker.go b/module/execution_tracker.go index ce069c8fe..bca8b2e60 100644 --- a/module/execution_tracker.go +++ b/module/execution_tracker.go @@ -50,6 +50,13 @@ type ExecutionTracker struct { chained EventRecorder } +// SetEventStoreRecorder sets the optional event store recorder that receives +// copies of all execution events. This is used by the server to wire the +// SQLite event store without directly assigning the exported field. +func (t *ExecutionTracker) SetEventStoreRecorder(r EventRecorder) { + t.EventStoreRecorder = r +} + // RecordEvent implements EventRecorder. It is called by the Pipeline for each // execution event (step.started, step.completed, step.failed, etc.). // Events are recorded best-effort — errors are silently ignored. diff --git a/module/feature_flag_module.go b/module/feature_flag_module.go index 43b6fcfc2..9659b8399 100644 --- a/module/feature_flag_module.go +++ b/module/feature_flag_module.go @@ -80,6 +80,8 @@ func (m *FeatureFlagModule) Init(_ modular.Application) error { return nil } // ProvidesServices implements modular.Module. The service is registered under // the module name so other modules (and the engine) can look it up. +// A FeatureFlagAdmin adapter is also registered so that the V1 API handler +// can discover it from the service registry without explicit wiring in main. func (m *FeatureFlagModule) ProvidesServices() []modular.ServiceProvider { providers := []modular.ServiceProvider{ { @@ -88,13 +90,23 @@ func (m *FeatureFlagModule) ProvidesServices() []modular.ServiceProvider { Instance: m.service, }, } - // Also register the store if it's a generic provider so the admin API can use it + // Register the admin adapter so V1APIHandler can discover FeatureFlagAdmin + // from the service registry — eliminating explicit wiring in the server. if m.store != nil { - providers = append(providers, modular.ServiceProvider{ - Name: m.name + ".store", - Description: "Feature flag store: " + m.name, - Instance: m.store, - }) + adapter := NewFeatureFlagAdminAdapter(m.service, m.store) + providers = append(providers, + modular.ServiceProvider{ + Name: m.name + ".admin", + Description: "Feature flag admin adapter: " + m.name, + Instance: FeatureFlagAdmin(adapter), + }, + // Also register the store for other consumers. + modular.ServiceProvider{ + Name: m.name + ".store", + Description: "Feature flag store: " + m.name, + Instance: m.store, + }, + ) } return providers } diff --git a/module/integration.go b/module/integration.go index 9690c8c5b..a23499014 100644 --- a/module/integration.go +++ b/module/integration.go @@ -151,10 +151,16 @@ func (c *HTTPIntegrationConnector) SetTimeout(timeout time.Duration) { c.client.Timeout = timeout } -// SetAllowPrivateIPs enables or disables requests to private/internal IP addresses. +// AllowPrivateIPs enables requests to private/internal IP addresses. // This should only be used for testing or trusted internal services. -func (c *HTTPIntegrationConnector) SetAllowPrivateIPs(allow bool) { - c.allowPrivateIPs = allow +func (c *HTTPIntegrationConnector) AllowPrivateIPs() { + c.allowPrivateIPs = true +} + +// DisallowPrivateIPs disables requests to private/internal IP addresses, +// restoring SSRF protection (this is the default). +func (c *HTTPIntegrationConnector) DisallowPrivateIPs() { + c.allowPrivateIPs = false } // SetRateLimit sets a rate limit for requests diff --git a/module/integration_test.go b/module/integration_test.go index 89e60389f..67e699cd6 100644 --- a/module/integration_test.go +++ b/module/integration_test.go @@ -166,7 +166,7 @@ func TestHTTPIntegrationConnector_ExecuteGET(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() ctx := context.Background() _ = c.Connect(ctx) @@ -195,7 +195,7 @@ func TestHTTPIntegrationConnector_ExecutePOST(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() ctx := context.Background() _ = c.Connect(ctx) @@ -221,7 +221,7 @@ func TestHTTPIntegrationConnector_ExecuteWithBasicAuth(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() c.SetBasicAuth("admin", "secret") ctx := context.Background() _ = c.Connect(ctx) @@ -248,7 +248,7 @@ func TestHTTPIntegrationConnector_ExecuteWithBearerAuth(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() c.SetBearerAuth("my-token") ctx := context.Background() _ = c.Connect(ctx) @@ -273,7 +273,7 @@ func TestHTTPIntegrationConnector_ExecuteCustomHeaders(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() c.SetHeader("X-Custom", "my-value") ctx := context.Background() _ = c.Connect(ctx) @@ -293,7 +293,7 @@ func TestHTTPIntegrationConnector_ExecuteErrorStatus(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() ctx := context.Background() _ = c.Connect(ctx) @@ -314,7 +314,7 @@ func TestHTTPIntegrationConnector_ExecuteNonJSONResponse(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() ctx := context.Background() _ = c.Connect(ctx) @@ -334,7 +334,7 @@ func TestHTTPIntegrationConnector_ExecuteEmptyResponse(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() ctx := context.Background() _ = c.Connect(ctx) @@ -468,7 +468,7 @@ func TestStdIntegrationRegistry_StartAndStop(t *testing.T) { defer server.Close() c := NewHTTPIntegrationConnector("test-conn", server.URL) - c.SetAllowPrivateIPs(true) + c.AllowPrivateIPs() r.RegisterConnector(c) if err := r.Start(); err != nil {