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
33 changes: 33 additions & 0 deletions cmd/wfctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,36 @@ modules:
schema.UnregisterModuleType("custom.ext.validate.testonly")
})
}

func TestRunValidatePluginDirCapabilities(t *testing.T) {
// Create a fake external plugin directory with a plugin.json using the
// v0.3.0+ nested "capabilities" object format (as used by registry manifests and older installers).
pluginsDir := t.TempDir()
pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin-caps")
if err := os.MkdirAll(pluginSubdir, 0755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"my-ext-plugin-caps","version":"1.0.0","type":"external","capabilities":{"configProvider":false,"moduleTypes":["custom.caps.validate.testonly"],"stepTypes":["step.caps_validate_testonly"],"triggerTypes":[]}}`
if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}

// Config using the module type declared in capabilities
dir := t.TempDir()
configContent := "modules:\n - name: caps-mod\n type: custom.caps.validate.testonly\n"
path := writeTestConfig(t, dir, "workflow.yaml", configContent)

// Without --plugin-dir: should fail (unknown type)
if err := runValidate([]string{path}); err == nil {
t.Fatal("expected error for unknown external module type without --plugin-dir")
}

// With --plugin-dir: should pass (types from capabilities object are recognized)
if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil {
t.Errorf("expected valid config with --plugin-dir (capabilities format), got: %v", err)
}
t.Cleanup(func() {
schema.UnregisterModuleType("custom.caps.validate.testonly")
schema.UnregisterModuleType("step.caps_validate_testonly")
})
}
36 changes: 36 additions & 0 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,11 +383,27 @@ func KnownWorkflowTypes() []string {

// pluginManifestTypes holds the type declarations from a plugin.json manifest.
// This is a minimal subset of the full plugin manifest to avoid import cycles.
// It supports both the flat format (types at root level) and the registry-manifest
// nested capabilities object format.
type pluginManifestTypes struct {
ModuleTypes []string `json:"moduleTypes"`
StepTypes []string `json:"stepTypes"`
TriggerTypes []string `json:"triggerTypes"`
WorkflowTypes []string `json:"workflowTypes"`
// Capabilities is stored as raw JSON to safely handle both the registry-manifest
// format (object with moduleTypes/stepTypes/etc.) and the engine-internal format
// (array of CapabilityDecl). A non-object value is silently ignored.
Capabilities json.RawMessage `json:"capabilities,omitempty"`
}

// pluginManifestCapabilities holds the nested capabilities object used in the
// registry manifest plugin.json format (not the engine-internal format, which
// uses a JSON array of CapabilityDecl instead).
type pluginManifestCapabilities struct {
ModuleTypes []string `json:"moduleTypes"`
StepTypes []string `json:"stepTypes"`
TriggerTypes []string `json:"triggerTypes"`
WorkflowHandlers []string `json:"workflowHandlers"`
}

// LoadPluginTypesFromDir scans pluginDir for subdirectories containing a
Expand Down Expand Up @@ -426,6 +442,26 @@ func LoadPluginTypesFromDir(pluginDir string) error {
for _, t := range m.WorkflowTypes {
RegisterWorkflowType(t)
}
// Also handle the registry-manifest nested capabilities object format.
// The capabilities field may be a JSON array (engine-internal CapabilityDecl format)
// or a JSON object (registry manifest format). Only process it when it's an object.
if len(m.Capabilities) > 0 && m.Capabilities[0] == '{' {
var cap pluginManifestCapabilities
if err := json.Unmarshal(m.Capabilities, &cap); err == nil {
for _, t := range cap.ModuleTypes {
RegisterModuleType(t)
}
for _, t := range cap.StepTypes {
RegisterModuleType(t)
}
for _, t := range cap.TriggerTypes {
RegisterTriggerType(t)
}
for _, t := range cap.WorkflowHandlers {
RegisterWorkflowType(t)
}
}
}
}
return nil
}
Expand Down
89 changes: 89 additions & 0 deletions schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,95 @@ func TestLoadPluginTypesFromDir_MalformedManifest(t *testing.T) {
}
}

func TestLoadPluginTypesFromDir_CapabilitiesFormat(t *testing.T) {
// Tests the v0.3.0+ nested "capabilities" object format used by external plugins.
const customModuleType = "external.caps.module.testonly"
const customStepType = "step.caps_step_testonly"
const customTriggerType = "external.caps.trigger.testonly"

t.Cleanup(func() {
UnregisterModuleType(customModuleType)
UnregisterModuleType(customStepType)
UnregisterTriggerType(customTriggerType)
})

dir := t.TempDir()
pluginDir := dir + "/caps-plugin"
if err := makeDir(pluginDir); err != nil {
t.Fatal(err)
}
manifest := `{"name":"caps-plugin","version":"1.0.0","type":"external","capabilities":{"configProvider":false,"moduleTypes":["` + customModuleType + `"],"stepTypes":["` + customStepType + `"],"triggerTypes":["` + customTriggerType + `"]}}`
if err := writeFile(pluginDir+"/plugin.json", []byte(manifest)); err != nil {
t.Fatal(err)
}

if err := LoadPluginTypesFromDir(dir); err != nil {
t.Fatalf("unexpected error: %v", err)
}

// All types declared in capabilities should now be recognized
cfg := &config.WorkflowConfig{
Modules: []config.ModuleConfig{
{Name: "ext", Type: customModuleType},
},
}
if err := ValidateConfig(cfg, WithAllowNoEntryPoints()); err != nil {
t.Errorf("expected capabilities module type to be recognized, got: %v", err)
}

knownModules := KnownModuleTypes()
if !sliceContains(knownModules, customModuleType) {
t.Errorf("expected %q in KnownModuleTypes, got: %v", customModuleType, knownModules)
}
if !sliceContains(knownModules, customStepType) {
t.Errorf("expected %q in KnownModuleTypes (step), got: %v", customStepType, knownModules)
}
knownTriggers := KnownTriggerTypes()
if !sliceContains(knownTriggers, customTriggerType) {
t.Errorf("expected %q in KnownTriggerTypes, got: %v", customTriggerType, knownTriggers)
}
}

func sliceContains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}

func TestLoadPluginTypesFromDir_CapabilitiesArrayFormat(t *testing.T) {
// Tests that a plugin.json using the engine-internal format (capabilities as a JSON
// array of CapabilityDecl objects) does not break loading of flat top-level types.
const customModuleType = "external.caps.array.module.testonly"

t.Cleanup(func() {
UnregisterModuleType(customModuleType)
})

dir := t.TempDir()
pluginDir := dir + "/array-caps-plugin"
if err := makeDir(pluginDir); err != nil {
t.Fatal(err)
}
// capabilities is a JSON array (engine-internal CapabilityDecl format)
manifest := `{"name":"array-caps-plugin","version":"1.0.0","moduleTypes":["` + customModuleType + `"],"capabilities":[{"name":"http-server","role":"provider"}]}`
if err := writeFile(pluginDir+"/plugin.json", []byte(manifest)); err != nil {
t.Fatal(err)
}

if err := LoadPluginTypesFromDir(dir); err != nil {
t.Fatalf("unexpected error: %v", err)
}

// The flat top-level module type should still be registered despite the array-format capabilities
knownModules := KnownModuleTypes()
if !sliceContains(knownModules, customModuleType) {
t.Errorf("expected %q in KnownModuleTypes even with array capabilities, got: %v", customModuleType, knownModules)
}
}

func makeDir(path string) error {
return os.MkdirAll(path, 0755)
}
Expand Down
Loading