Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,29 @@ func (e *StdEngine) SetPluginLoader(loader *plugin.PluginLoader) {

// LoadPlugin loads an EnginePlugin into the engine.
func (e *StdEngine) LoadPlugin(p plugin.EnginePlugin) error {
return e.loadPluginInternal(p, false)
}

// LoadPluginWithOverride loads an EnginePlugin into the engine, allowing it to
// override existing module, step, trigger, handler, deploy target, and sidecar
// provider registrations. When a duplicate type is encountered, the new factory
// replaces the previous one and a warning is logged, instead of returning an
// error. This is intended for external plugins that intentionally replace
// built-in defaults (e.g., replacing a mock step with a production
// implementation, or swapping out deploy targets/sidecar providers).
func (e *StdEngine) LoadPluginWithOverride(p plugin.EnginePlugin) error {
return e.loadPluginInternal(p, true)
}

func (e *StdEngine) loadPluginInternal(p plugin.EnginePlugin, allowOverride bool) error {
loader := e.PluginLoader()
if err := loader.LoadPlugin(p); err != nil {
var err error
if allowOverride {
err = loader.LoadPluginWithOverride(p)
} else {
err = loader.LoadPlugin(p)
}
if err != nil {
return fmt.Errorf("load plugin: %w", err)
}
for typeName, factory := range p.ModuleFactories() {
Expand Down
74 changes: 61 additions & 13 deletions plugin/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,23 @@ func (l *PluginLoader) LoadBinaryPlugin(p EnginePlugin, binaryPath, sigPath, cer
return l.LoadPlugin(p)
}

// ValidateTier checks whether a plugin's tier is allowed given the current
// LoadBinaryPluginWithOverride is the override-capable counterpart to
// LoadBinaryPlugin. It verifies the plugin binary with cosign (for premium
// plugins) and then loads the plugin, allowing it to override existing module,
// step, trigger, handler, deploy target, and sidecar provider registrations.
// When a duplicate type is encountered, the new factory replaces the previous
// one and a warning is logged instead of returning an error.
func (l *PluginLoader) LoadBinaryPluginWithOverride(p EnginePlugin, binaryPath, sigPath, certPath string) error {
manifest := p.EngineManifest()
if manifest.Tier == TierPremium && l.cosignVerifier != nil {
if err := l.cosignVerifier.Verify(binaryPath, sigPath, certPath); err != nil {
return fmt.Errorf("plugin %q: binary verification failed: %w", manifest.Name, err)
}
}
return l.LoadPluginWithOverride(p)
}


// license validator configuration:
// - Core and Community plugins are always allowed.
// - Premium plugins are validated against the LicenseValidator if one is set.
Expand Down Expand Up @@ -106,6 +122,20 @@ func (l *PluginLoader) ValidateTier(manifest *PluginManifest) error {
// schemas, and wiring hooks. Returns an error if any factory type conflicts with
// an existing registration.
func (l *PluginLoader) LoadPlugin(p EnginePlugin) error {
return l.loadPlugin(p, false)
}

// LoadPluginWithOverride is like LoadPlugin but allows the plugin to override
// existing module, step, trigger, handler, deploy target, and sidecar provider
// registrations. When a duplicate type is encountered, the new factory replaces
// the previous one and a warning is logged instead of returning an error.
// This is intended for external plugins that intentionally replace built-in
// defaults (e.g., replacing a mock authz step with a production implementation).
func (l *PluginLoader) LoadPluginWithOverride(p EnginePlugin) error {
return l.loadPlugin(p, true)
}

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PluginLoader has LoadBinaryPlugin() which currently always calls LoadPlugin() (duplicate types rejected). With the new override capability, consider adding a LoadBinaryPluginWithOverride() (or an override flag) so binary-loaded external plugins can intentionally replace built-ins as well.

Suggested change
// LoadBinaryPluginWithOverride is the override-capable counterpart to loading
// binary or externally provided plugins. It behaves like LoadPluginWithOverride,
// allowing a binary-loaded plugin to intentionally replace built-in module,
// step, trigger, handler, deploy target, and sidecar provider registrations.
func (l *PluginLoader) LoadBinaryPluginWithOverride(p EnginePlugin) error {
return l.loadPlugin(p, true)
}

Copilot uses AI. Check for mistakes.
func (l *PluginLoader) loadPlugin(p EnginePlugin, allowOverride bool) error {
manifest := p.EngineManifest()
if err := manifest.Validate(); err != nil {
return fmt.Errorf("plugin %q: %w", manifest.Name, err)
Expand All @@ -132,34 +162,46 @@ func (l *PluginLoader) LoadPlugin(p EnginePlugin) error {
}
}

// Register module factories — conflict on duplicate type.
// Register module factories — conflict on duplicate type unless override allowed.
for typeName, factory := range p.ModuleFactories() {
if _, exists := l.moduleFactories[typeName]; exists {
return fmt.Errorf("plugin %q: module type %q already registered", manifest.Name, typeName)
if !allowOverride {
return fmt.Errorf("plugin %q: module type %q already registered", manifest.Name, typeName)
}
slog.Warn("plugin overriding existing module type", "plugin", manifest.Name, "type", typeName)
}
l.moduleFactories[typeName] = factory
}

// Register step factories — conflict on duplicate type.
// Register step factories — conflict on duplicate type unless override allowed.
for typeName, factory := range p.StepFactories() {
if _, exists := l.stepFactories[typeName]; exists {
return fmt.Errorf("plugin %q: step type %q already registered", manifest.Name, typeName)
if !allowOverride {
return fmt.Errorf("plugin %q: step type %q already registered", manifest.Name, typeName)
}
slog.Warn("plugin overriding existing step type", "plugin", manifest.Name, "type", typeName)
}
l.stepFactories[typeName] = factory
}

// Register trigger factories — conflict on duplicate type.
// Register trigger factories — conflict on duplicate type unless override allowed.
for typeName, factory := range p.TriggerFactories() {
if _, exists := l.triggerFactories[typeName]; exists {
return fmt.Errorf("plugin %q: trigger type %q already registered", manifest.Name, typeName)
if !allowOverride {
return fmt.Errorf("plugin %q: trigger type %q already registered", manifest.Name, typeName)
}
slog.Warn("plugin overriding existing trigger type", "plugin", manifest.Name, "type", typeName)
}
l.triggerFactories[typeName] = factory
}

// Register workflow handler factories — conflict on duplicate type.
// Register workflow handler factories — conflict on duplicate type unless override allowed.
for typeName, factory := range p.WorkflowHandlers() {
if _, exists := l.handlerFactories[typeName]; exists {
return fmt.Errorf("plugin %q: workflow handler type %q already registered", manifest.Name, typeName)
if !allowOverride {
return fmt.Errorf("plugin %q: workflow handler type %q already registered", manifest.Name, typeName)
}
slog.Warn("plugin overriding existing workflow handler type", "plugin", manifest.Name, "type", typeName)
}
l.handlerFactories[typeName] = factory
}
Expand All @@ -175,18 +217,24 @@ func (l *PluginLoader) LoadPlugin(p EnginePlugin) error {
// Collect config transform hooks.
l.configTransformHooks = append(l.configTransformHooks, p.ConfigTransformHooks()...)

// Register deploy targets — conflict on duplicate name.
// Register deploy targets — conflict on duplicate name unless override allowed.
for name, target := range p.DeployTargets() {
if _, exists := l.deployTargets[name]; exists {
return fmt.Errorf("plugin %q: deploy target %q already registered", manifest.Name, name)
if !allowOverride {
return fmt.Errorf("plugin %q: deploy target %q already registered", manifest.Name, name)
}
slog.Warn("plugin overriding existing deploy target", "plugin", manifest.Name, "target", name)
}
l.deployTargets[name] = target
}

// Register sidecar providers — conflict on duplicate type.
// Register sidecar providers — conflict on duplicate type unless override allowed.
for typeName, provider := range p.SidecarProviders() {
if _, exists := l.sidecarProviders[typeName]; exists {
return fmt.Errorf("plugin %q: sidecar provider %q already registered", manifest.Name, typeName)
if !allowOverride {
return fmt.Errorf("plugin %q: sidecar provider %q already registered", manifest.Name, typeName)
}
slog.Warn("plugin overriding existing sidecar provider", "plugin", manifest.Name, "type", typeName)
}
l.sidecarProviders[typeName] = provider
}
Expand Down
206 changes: 206 additions & 0 deletions plugin/loader_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package plugin

import (
"context"
"io"
"testing"

"github.com/CrisisTextLine/modular"
"github.com/GoCodeAlone/workflow/capability"
"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/deploy"
"github.com/GoCodeAlone/workflow/schema"
)

Expand Down Expand Up @@ -166,6 +169,158 @@ func TestPluginLoader_DuplicateModuleTypeConflict(t *testing.T) {
}
}

func TestPluginLoader_LoadPluginWithOverride_ModuleType(t *testing.T) {
loader := newTestEngineLoader()

p1 := &modulePlugin{
BaseEnginePlugin: *makeEnginePlugin("builtin-plugin", "1.0.0", nil),
modules: map[string]ModuleFactory{
"shared.module": func(name string, cfg map[string]any) modular.Module {
return nil
},
},
}
p2 := &modulePlugin{
BaseEnginePlugin: *makeEnginePlugin("external-plugin", "1.0.0", nil),
modules: map[string]ModuleFactory{
"shared.module": func(name string, cfg map[string]any) modular.Module {
return nil
},
},
}

if err := loader.LoadPlugin(p1); err != nil {
t.Fatalf("first load should succeed: %v", err)
}
// LoadPlugin should still reject duplicates.
if err := loader.LoadPlugin(p2); err == nil {
t.Fatal("expected duplicate module type error from LoadPlugin")
}
// LoadPluginWithOverride should allow replacing the type.
if err := loader.LoadPluginWithOverride(p2); err != nil {
t.Fatalf("LoadPluginWithOverride should succeed: %v", err)
}
if got := len(loader.ModuleFactories()); got != 1 {
t.Errorf("expected 1 module factory after override, got %d", got)
}
if got := len(loader.LoadedPlugins()); got != 2 {
t.Errorf("expected 2 loaded plugins, got %d", got)
}
}

func TestPluginLoader_LoadPluginWithOverride_StepType(t *testing.T) {
loader := newTestEngineLoader()

p1 := &modulePlugin{
BaseEnginePlugin: *makeEnginePlugin("builtin-steps", "1.0.0", nil),
steps: map[string]StepFactory{
"step.authz_check": func(name string, cfg map[string]any, _ modular.Application) (any, error) {
return "builtin", nil
},
},
}
p2 := &modulePlugin{
BaseEnginePlugin: *makeEnginePlugin("external-authz", "1.0.0", nil),
steps: map[string]StepFactory{
"step.authz_check": func(name string, cfg map[string]any, _ modular.Application) (any, error) {
return "external", nil
},
},
}

if err := loader.LoadPlugin(p1); err != nil {
t.Fatalf("first load should succeed: %v", err)
}
// LoadPlugin should still reject duplicate step types.
if err := loader.LoadPlugin(p2); err == nil {
t.Fatal("expected duplicate step type error from LoadPlugin")
}
if err := loader.LoadPluginWithOverride(p2); err != nil {
t.Fatalf("LoadPluginWithOverride should succeed: %v", err)
}

// Verify the override replaced the factory.
factories := loader.StepFactories()
if got := len(factories); got != 1 {
t.Fatalf("expected 1 step factory, got %d", got)
}
result, err := factories["step.authz_check"]("test", nil, nil)
if err != nil {
t.Fatalf("step factory returned error: %v", err)
}
if result != "external" {
t.Errorf("expected overridden factory to return %q, got %q", "external", result)
}
}

func TestPluginLoader_LoadPluginWithOverride_AllTypes(t *testing.T) {
loader := newTestEngineLoader()

p1 := &fullPlugin{
BaseEnginePlugin: *makeEnginePlugin("builtin", "1.0.0", nil),
modules: map[string]ModuleFactory{
"mod.type": func(name string, cfg map[string]any) modular.Module { return nil },
},
steps: map[string]StepFactory{
"step.type": func(name string, cfg map[string]any, _ modular.Application) (any, error) { return nil, nil },
},
triggers: map[string]TriggerFactory{
"trigger.type": func() any { return nil },
},
handlers: map[string]WorkflowHandlerFactory{
"handler.type": func() any { return nil },
},
deployTargets: map[string]deploy.DeployTarget{"deploy.target": &mockDeployTarget{name: "builtin-target"}},
sidecarProviders: map[string]deploy.SidecarProvider{"sidecar.type": &mockSidecarProvider{typeName: "builtin-sidecar"}},
}
p2 := &fullPlugin{
BaseEnginePlugin: *makeEnginePlugin("external", "1.0.0", nil),
modules: map[string]ModuleFactory{
"mod.type": func(name string, cfg map[string]any) modular.Module { return nil },
},
steps: map[string]StepFactory{
"step.type": func(name string, cfg map[string]any, _ modular.Application) (any, error) { return nil, nil },
},
triggers: map[string]TriggerFactory{
"trigger.type": func() any { return nil },
},
handlers: map[string]WorkflowHandlerFactory{
"handler.type": func() any { return nil },
},
deployTargets: map[string]deploy.DeployTarget{"deploy.target": &mockDeployTarget{name: "external-target"}},
sidecarProviders: map[string]deploy.SidecarProvider{"sidecar.type": &mockSidecarProvider{typeName: "external-sidecar"}},
}

if err := loader.LoadPlugin(p1); err != nil {
t.Fatalf("first load should succeed: %v", err)
}
// Verify LoadPlugin rejects all duplicate types.
if err := loader.LoadPlugin(p2); err == nil {
t.Fatal("expected duplicate type error from LoadPlugin")
}
if err := loader.LoadPluginWithOverride(p2); err != nil {
t.Fatalf("LoadPluginWithOverride should succeed for all types: %v", err)
}
if got := len(loader.ModuleFactories()); got != 1 {
t.Errorf("expected 1 module factory, got %d", got)
}
if got := len(loader.StepFactories()); got != 1 {
t.Errorf("expected 1 step factory, got %d", got)
}
if got := len(loader.TriggerFactories()); got != 1 {
t.Errorf("expected 1 trigger factory, got %d", got)
}
if got := len(loader.WorkflowHandlerFactories()); got != 1 {
t.Errorf("expected 1 handler factory, got %d", got)
}
if got := len(loader.DeployTargets()); got != 1 {
t.Errorf("expected 1 deploy target, got %d", got)
}
if got := len(loader.SidecarProviders()); got != 1 {
t.Errorf("expected 1 sidecar provider, got %d", got)
}
}

func TestPluginLoader_WiringHooksSortedByPriority(t *testing.T) {
loader := newTestEngineLoader()

Expand Down Expand Up @@ -235,3 +390,54 @@ type hookPlugin struct {
}

func (p *hookPlugin) WiringHooks() []WiringHook { return p.hooks }

// fullPlugin embeds BaseEnginePlugin and overrides all factory methods including
// deploy targets and sidecar providers.
type fullPlugin struct {
BaseEnginePlugin
modules map[string]ModuleFactory
steps map[string]StepFactory
triggers map[string]TriggerFactory
handlers map[string]WorkflowHandlerFactory
deployTargets map[string]deploy.DeployTarget
sidecarProviders map[string]deploy.SidecarProvider
}

func (p *fullPlugin) ModuleFactories() map[string]ModuleFactory { return p.modules }
func (p *fullPlugin) StepFactories() map[string]StepFactory { return p.steps }
func (p *fullPlugin) TriggerFactories() map[string]TriggerFactory { return p.triggers }
func (p *fullPlugin) WorkflowHandlers() map[string]WorkflowHandlerFactory { return p.handlers }
func (p *fullPlugin) DeployTargets() map[string]deploy.DeployTarget { return p.deployTargets }
func (p *fullPlugin) SidecarProviders() map[string]deploy.SidecarProvider {
return p.sidecarProviders
}

// mockDeployTarget is a no-op deploy target for tests.
type mockDeployTarget struct{ name string }

func (m *mockDeployTarget) Name() string { return m.name }
func (m *mockDeployTarget) Generate(_ context.Context, _ *deploy.DeployRequest) (*deploy.DeployArtifacts, error) {
return nil, nil
}
func (m *mockDeployTarget) Apply(_ context.Context, _ *deploy.DeployArtifacts, _ deploy.ApplyOpts) (*deploy.DeployResult, error) {
return nil, nil
}
func (m *mockDeployTarget) Destroy(_ context.Context, _, _ string) error { return nil }
func (m *mockDeployTarget) Status(_ context.Context, _, _ string) (*deploy.DeployStatus, error) {
return nil, nil
}
func (m *mockDeployTarget) Diff(_ context.Context, _ *deploy.DeployArtifacts) (string, error) {
return "", nil
}
func (m *mockDeployTarget) Logs(_ context.Context, _, _ string, _ deploy.LogOpts) (io.ReadCloser, error) {
return nil, nil
}

// mockSidecarProvider is a no-op sidecar provider for tests.
type mockSidecarProvider struct{ typeName string }

func (m *mockSidecarProvider) Type() string { return m.typeName }
func (m *mockSidecarProvider) Validate(_ config.SidecarConfig) error { return nil }
func (m *mockSidecarProvider) Resolve(_ config.SidecarConfig, _ string) (*deploy.SidecarSpec, error) {
return nil, nil
}
Loading