From 1918509b8f9078131eeb3d70c38d943f693ca75e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 23 May 2026 10:44:15 -0400 Subject: [PATCH] fix(wfctl): validate resolves local plugin.json manifests (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wfctl validate previously required referenced plugin types to already be installed in the active plugin registry. Scenario/sample apps that bundle or sit beside plugin sources (e.g. workflow-compute-scenarios) could not be validated from a clean checkout — `compute.provider_catalog` and similar declared module types were reported as unknown. This change makes validate manifest-aware in two complementary ways: * `--plugin-manifest `: repeatable. Accepts a plugin.json file, a directory containing one, or a directory of plugin subdirs. The declared module/step/trigger/workflow/resource types and step schemas are registered before validation. Missing paths surface as errors so typos don't silently degrade to a half-loaded validation context. * Auto-resolution of `requires.plugins[]`: each declared plugin name is searched at `//plugin.json`, `/plugins//plugin.json`, and `/providers//plugin.json`, walking up to three parent directories. Hidden, underscored, vendor/node_modules/_worktrees dirs are skipped. `--no-resolve-plugins` opts out. Implementation extracts the shared manifest-parsing path in `schema.LoadPluginTypesFromDir` into a reusable `registerPluginManifestTypes` helper and adds the corresponding single-manifest entry points (`LoadPluginTypesFromManifest`, `LoadPluginStepSchemasFromManifest`). No config schema changes. Verified against the issue's reproduction: the edge-risk-workflow scenario now passes with zero flags, auto-resolving compute, edge-compute, and edge-risk plugins from their checked-out locations. Refs #756 --- cmd/wfctl/legacy_aws_types_removed_test.go | 2 +- cmd/wfctl/legacy_do_types_removed_test.go | 2 +- cmd/wfctl/main_test.go | 2 +- cmd/wfctl/validate.go | 27 ++- cmd/wfctl/validate_local_manifests_test.go | 208 +++++++++++++++++++++ cmd/wfctl/validate_plugin_manifests.go | 151 +++++++++++++++ docs/WFCTL.md | 14 ++ schema/schema.go | 114 ++++++----- schema/step_schema.go | 39 ++-- 9 files changed, 494 insertions(+), 65 deletions(-) create mode 100644 cmd/wfctl/validate_local_manifests_test.go create mode 100644 cmd/wfctl/validate_plugin_manifests.go diff --git a/cmd/wfctl/legacy_aws_types_removed_test.go b/cmd/wfctl/legacy_aws_types_removed_test.go index f7761fb6..9a2cc2b8 100644 --- a/cmd/wfctl/legacy_aws_types_removed_test.go +++ b/cmd/wfctl/legacy_aws_types_removed_test.go @@ -47,7 +47,7 @@ func TestValidateFile_LegacyAWSModule_ReturnsActionableError(t *testing.T) { if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { t.Fatal(err) } - err := validateFile(cfgPath, false, false, false) + err := validateFile(cfgPath, false, false, false, false) if err == nil { t.Fatal("expected error for legacy AWS module type") } diff --git a/cmd/wfctl/legacy_do_types_removed_test.go b/cmd/wfctl/legacy_do_types_removed_test.go index b109eb7e..aff68cf0 100644 --- a/cmd/wfctl/legacy_do_types_removed_test.go +++ b/cmd/wfctl/legacy_do_types_removed_test.go @@ -45,7 +45,7 @@ func TestValidateFile_LegacyDOModule_ReturnsActionableError(t *testing.T) { if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { t.Fatal(err) } - err := validateFile(cfgPath, false, false, false) + err := validateFile(cfgPath, false, false, false, false) if err == nil { t.Fatal("expected error for legacy DO module type") } diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 54407f7a..b4daa349 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -354,7 +354,7 @@ triggers: ` path := writeTestConfig(t, dir, "snake.yaml", snakeCaseConfig) // validateFile returns the detailed error; runValidate returns a summary - err := validateFile(path, false, false, false) + err := validateFile(path, false, false, false, false) if err == nil { t.Fatal("expected error for snake_case config field") } diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index ba46c3f1..4d5f345b 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -25,6 +25,9 @@ func runValidate(args []string) error { allowNoEntryPoints := fs.Bool("allow-no-entry-points", false, "Allow configs with no entry points (triggers, routes, subscriptions, jobs)") dir := fs.String("dir", "", "Validate all .yaml/.yml files in a directory (recursive)") pluginDir := fs.String("plugin-dir", "", "Directory of installed external plugins; their types are loaded before validation") + var pluginManifests stringSliceFlag + fs.Var(&pluginManifests, "plugin-manifest", "Path to a plugin.json file, or a directory containing one (or one level of subdirs that do). Repeatable. Loaded before validation so the declared types pass.") + noAutoResolve := fs.Bool("no-resolve-plugins", false, "Disable auto-resolution of requires.plugins[] against sibling/ancestor checkouts") fs.Usage = func() { fmt.Fprintf(fs.Output(), `Usage: wfctl validate [options] [config2.yaml ...] @@ -37,6 +40,8 @@ Examples: wfctl validate --loose legacy/config.yaml wfctl validate --skip-unknown-types example/*.yaml wfctl validate --plugin-dir data/plugins config.yaml + wfctl validate --plugin-manifest ../workflow-plugin-foo config.yaml + wfctl validate --plugin-manifest ../workflow-plugin-foo/plugin.json config.yaml Options: `) @@ -62,6 +67,11 @@ Options: } schema.LoadPluginStepSchemasFromDir(*pluginDir) } + for _, manifest := range pluginManifests { + if err := loadPluginManifestPath(manifest); err != nil { + return err + } + } // Collect files to validate var files []string @@ -95,7 +105,7 @@ Options: ) for _, f := range files { - if err := validateFile(f, *strict, *skipUnknownTypes, *allowNoEntryPoints); err != nil { + if err := validateFile(f, *strict, *skipUnknownTypes, *allowNoEntryPoints, !*noAutoResolve); err != nil { failed++ errors = append(errors, fmt.Sprintf(" FAIL %s\n %s", f, indentError(err))) } else { @@ -134,7 +144,7 @@ func indentErrorMessage(message string) string { return strings.TrimSpace(lines[len(lines)-1]) } -func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints bool) error { +func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints, autoResolvePlugins bool) error { // Read raw YAML to extract imports list for verbose feedback. imports := extractImports(cfgPath) if isLikelyWfctlProjectManifest(cfgPath) { @@ -150,6 +160,10 @@ func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints b fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(imports), strings.Join(imports, ", ")) } + if autoResolvePlugins && cfg.Requires != nil { + autoResolveRequiredPlugins(cfgPath, cfg.Requires.Plugins) + } + var opts []schema.ValidationOption if !strict { opts = append(opts, schema.WithAllowEmptyModules()) @@ -365,10 +379,11 @@ func reorderFlags(args []string) []string { var flags, positional []string // flags that take a value argument (not self-contained with "=") valueFlagNames := map[string]bool{ - "dir": true, - "lock-file": true, - "manifest": true, - "plugin-dir": true, + "dir": true, + "lock-file": true, + "manifest": true, + "plugin-dir": true, + "plugin-manifest": true, } for i := 0; i < len(args); i++ { if strings.HasPrefix(args[i], "-") { diff --git a/cmd/wfctl/validate_local_manifests_test.go b/cmd/wfctl/validate_local_manifests_test.go new file mode 100644 index 00000000..4cf4cf69 --- /dev/null +++ b/cmd/wfctl/validate_local_manifests_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/schema" +) + +// Issue #756: wfctl validate must recognize module/step/trigger types declared +// in local plugin.json manifests when the workflow config references those +// types via requires.plugins[]. +// +// Two surfaces are tested: +// 1. --plugin-manifest : explicit path to a plugin.json (or a directory +// containing one). Operator-driven override. +// 2. Auto-resolution of requires.plugins[] against conventional sibling and +// ancestor locations of the config file. Convention over configuration. + +const issue756ConfigBody = `requires: + plugins: + - name: workflow-plugin-issue756 +modules: + - name: ext + type: issue756.module + - name: ext_step_owner + type: issue756.other_module +` + +const issue756ManifestBody = `{ + "name": "workflow-plugin-issue756", + "version": "0.1.0", + "capabilities": { + "moduleTypes": ["issue756.module", "issue756.other_module"] + } +}` + +func unregisterIssue756Types(t *testing.T) { + t.Helper() + schema.UnregisterModuleType("issue756.module") + schema.UnregisterModuleType("issue756.other_module") +} + +// Baseline: without any manifest resolution, validate fails because the +// referenced module types are not registered. This pins the bug. +func TestValidate_UnknownTypes_WithoutManifestResolution(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + + err := runValidate([]string{"--allow-no-entry-points", cfgPath}) + if err == nil { + t.Fatal("expected validation to fail when manifest is not discoverable") + } +} + +// --plugin-manifest pointing at the manifest file directly resolves types. +func TestValidate_PluginManifestFlag_PointingAtFile(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + manifestPath := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(manifestPath, []byte(issue756ManifestBody), 0644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + if err := runValidate([]string{"--allow-no-entry-points", "--plugin-manifest", manifestPath, cfgPath}); err != nil { + t.Fatalf("validate with --plugin-manifest : %v", err) + } +} + +// --plugin-manifest pointing at a directory containing plugin.json resolves types. +func TestValidate_PluginManifestFlag_PointingAtDir(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + pluginDir := filepath.Join(dir, "workflow-plugin-issue756") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(issue756ManifestBody), 0644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + if err := runValidate([]string{"--allow-no-entry-points", "--plugin-manifest", pluginDir, cfgPath}); err != nil { + t.Fatalf("validate with --plugin-manifest : %v", err) + } +} + +// --plugin-manifest is repeatable. +func TestValidate_PluginManifestFlag_Repeatable(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + + mA := filepath.Join(dir, "a.json") + mB := filepath.Join(dir, "b.json") + manifestA := `{"name":"a","capabilities":{"moduleTypes":["issue756.module"]}}` + manifestB := `{"name":"b","capabilities":{"moduleTypes":["issue756.other_module"]}}` + if err := os.WriteFile(mA, []byte(manifestA), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mB, []byte(manifestB), 0644); err != nil { + t.Fatal(err) + } + + if err := runValidate([]string{"--allow-no-entry-points", "--plugin-manifest", mA, "--plugin-manifest", mB, cfgPath}); err != nil { + t.Fatalf("validate with two --plugin-manifest flags: %v", err) + } +} + +// Auto-resolution: plugin.json found at //plugin.json. +func TestValidate_AutoResolve_SiblingPluginDir(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + pluginDir := filepath.Join(dir, "workflow-plugin-issue756") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(issue756ManifestBody), 0644); err != nil { + t.Fatal(err) + } + + if err := runValidate([]string{"--allow-no-entry-points", cfgPath}); err != nil { + t.Fatalf("validate with auto-resolved sibling plugin dir: %v", err) + } +} + +// Auto-resolution: plugin.json at /providers//plugin.json. +// Mirrors workflow-compute-scenarios layout where scenario-local plugins live +// under a providers/ subtree. +func TestValidate_AutoResolve_ProvidersSubdir(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + pluginDir := filepath.Join(dir, "providers", "workflow-plugin-issue756") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(issue756ManifestBody), 0644); err != nil { + t.Fatal(err) + } + + if err := runValidate([]string{"--allow-no-entry-points", cfgPath}); err != nil { + t.Fatalf("validate with auto-resolved providers/ subdir: %v", err) + } +} + +// Auto-resolution: plugin.json at workspace-sibling layout. +// +// workspace/myapp/workflow.yaml +// workspace/workflow-plugin-issue756/plugin.json +func TestValidate_AutoResolve_WorkspaceSibling(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + workspace := t.TempDir() + cfgDir := filepath.Join(workspace, "myapp", "apps", "edge") + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(cfgDir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte(issue756ConfigBody), 0644); err != nil { + t.Fatal(err) + } + + pluginDir := filepath.Join(workspace, "workflow-plugin-issue756") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(issue756ManifestBody), 0644); err != nil { + t.Fatal(err) + } + + if err := runValidate([]string{"--allow-no-entry-points", cfgPath}); err != nil { + t.Fatalf("validate with workspace-sibling layout: %v", err) + } +} + +// --plugin-manifest pointing at a path that does not exist must error so the +// operator notices a typo or missing file rather than silently validating with +// no extra types. +func TestValidate_PluginManifestFlag_MissingPathErrors(t *testing.T) { + unregisterIssue756Types(t) + t.Cleanup(func() { unregisterIssue756Types(t) }) + + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir, "workflow.yaml", issue756ConfigBody) + + err := runValidate([]string{"--allow-no-entry-points", "--plugin-manifest", filepath.Join(dir, "no-such.json"), cfgPath}) + if err == nil { + t.Fatal("expected --plugin-manifest pointing at missing path to error") + } +} diff --git a/cmd/wfctl/validate_plugin_manifests.go b/cmd/wfctl/validate_plugin_manifests.go new file mode 100644 index 00000000..bc0de09c --- /dev/null +++ b/cmd/wfctl/validate_plugin_manifests.go @@ -0,0 +1,151 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/schema" +) + +// loadPluginManifestPath resolves a --plugin-manifest argument and loads any +// type / step-schema declarations it contributes. +// +// Accepted shapes: +// - A path to a plugin.json file: loaded directly. +// - A path to a directory containing plugin.json: loaded as a single plugin. +// - A path to a directory whose immediate subdirectories contain plugin.json +// files: each subdir loaded (matches --plugin-dir semantics). +// +// A missing path is an error so a typo doesn't silently produce a half-loaded +// validation context. +func loadPluginManifestPath(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("--plugin-manifest %q: %w", path, err) + } + if !info.IsDir() { + if err := schema.LoadPluginTypesFromManifest(path); err != nil { + return err + } + schema.LoadPluginStepSchemasFromManifest(path) + return nil + } + + // Directory: prefer a plugin.json directly inside it; otherwise fall back to + // the legacy "//plugin.json" layout that LoadPluginTypesFromDir + // supports. + manifest := filepath.Join(path, "plugin.json") + if _, statErr := os.Stat(manifest); statErr == nil { + if err := schema.LoadPluginTypesFromManifest(manifest); err != nil { + return err + } + schema.LoadPluginStepSchemasFromManifest(manifest) + return nil + } + if err := schema.LoadPluginTypesFromDir(path); err != nil { + return fmt.Errorf("--plugin-manifest %q: %w", path, err) + } + schema.LoadPluginStepSchemasFromDir(path) + return nil +} + +// autoResolveRequiredPlugins walks conventional locations near cfgPath looking +// for a plugin.json that matches each entry in requires.plugins[]. The first +// match wins; failure to find a match is not an error so the existing +// "unknown type" message still fires when the operator forgot to check out the +// plugin source. +// +// Search order, per plugin name, with cfgDir = directory containing cfgPath: +// +// cfgDir//plugin.json +// cfgDir/plugins//plugin.json +// cfgDir/providers//plugin.json +// (repeat each form against cfgDir/.., cfgDir/../.., cfgDir/../../..) +// +// Hidden ("."), underscore-prefixed, node_modules, vendor, and _worktrees +// directories are skipped silently to avoid noisy false positives in larger +// workspaces. +func autoResolveRequiredPlugins(cfgPath string, plugins []config.PluginRequirement) { + if len(plugins) == 0 { + return + } + abs, err := filepath.Abs(cfgPath) + if err != nil { + return + } + cfgDir := filepath.Dir(abs) + + for _, p := range plugins { + if p.Name == "" { + continue + } + manifest := findLocalPluginManifest(cfgDir, p.Name) + if manifest == "" { + continue + } + if err := schema.LoadPluginTypesFromManifest(manifest); err != nil { + fmt.Fprintf(os.Stderr, " WARN auto-resolved manifest %s for %s failed to load: %v\n", manifest, p.Name, err) + continue + } + schema.LoadPluginStepSchemasFromManifest(manifest) + fmt.Fprintf(os.Stderr, " Auto-resolved plugin %s from %s\n", p.Name, manifest) + } +} + +// searchSubdirs is the set of conventional subdirectories under a candidate +// root that may hold plugin checkouts. Empty string represents the root itself. +var searchSubdirs = []string{"", "plugins", "providers"} + +// ancestorDepth is the number of parent directories above the config file +// considered during auto-resolution. Three levels covers the common +// workspace/scenario-repo/apps//workflow.yaml layout so a config nested +// three deep can still find sibling plugin checkouts at the workspace root, +// without scanning arbitrarily far up the filesystem. +const ancestorDepth = 3 + +var autoResolveSkipDirs = map[string]bool{ + "node_modules": true, + "vendor": true, + "_worktrees": true, + ".git": true, +} + +func findLocalPluginManifest(cfgDir, name string) string { + root := cfgDir + for level := 0; level <= ancestorDepth; level++ { + if shouldSkipAutoResolveDir(root) { + break + } + for _, sub := range searchSubdirs { + candidate := filepath.Join(root, sub, name, "plugin.json") + if sub == "" { + candidate = filepath.Join(root, name, "plugin.json") + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + parent := filepath.Dir(root) + if parent == root { + break + } + root = parent + } + return "" +} + +func shouldSkipAutoResolveDir(dir string) bool { + base := filepath.Base(dir) + if base == "." || base == "/" || base == "" { + return false + } + if autoResolveSkipDirs[base] { + return true + } + if len(base) > 1 && (base[0] == '.' || base[0] == '_') { + return true + } + return false +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 2c68fe23..b0df8760 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -428,6 +428,8 @@ wfctl validate [options] [config2.yaml ...] | `-allow-no-entry-points` | `false` | Allow configs with no triggers, routes, subscriptions, or jobs | | `-dir` | _(none)_ | Validate all `.yaml`/`.yml` files in a directory (recursive) | | `-plugin-dir` | _(none)_ | Directory of installed external plugins; their types are loaded before validation | +| `-plugin-manifest` | _(none)_ | Path to a `plugin.json` file, a directory containing one, or a directory of plugin checkouts. Repeatable. Loaded before validation so the manifest's module/step/trigger types are recognized. | +| `-no-resolve-plugins` | `false` | Disable automatic resolution of `requires.plugins[]` against sibling/ancestor checkouts of the config file | **Examples:** @@ -438,11 +440,23 @@ wfctl validate --dir ./example/ wfctl validate --loose legacy/config.yaml wfctl validate --skip-unknown-types example/*.yaml wfctl validate --plugin-dir data/plugins config.yaml +wfctl validate --plugin-manifest ../workflow-plugin-foo config.yaml +wfctl validate --plugin-manifest ../workflow-plugin-foo/plugin.json config.yaml ``` Use `wfctl config validate` for `wfctl.yaml` and `.wfctl-lock.yaml`; this command validates runtime Workflow configs such as `workflow.yaml`. +**Local plugin resolution.** When a config declares `requires.plugins[]`, +`wfctl validate` automatically searches sibling and ancestor directories of the +config file for a matching `plugin.json` so scenario or sample repositories can +be validated without installing every referenced plugin into a registry first. +Each plugin name is looked up at `//plugin.json`, +`/plugins//plugin.json`, and `/providers//plugin.json`, +walking up to three parent directories above the config file. Use +`--plugin-manifest` for an explicit override or `--no-resolve-plugins` to +disable the search entirely. + When validating multiple files, a summary is printed: ``` PASS example/api-server-config.yaml (5 modules, 3 workflows, 2 triggers) diff --git a/schema/schema.go b/schema/schema.go index e0a6acfe..432aaca8 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -557,56 +557,80 @@ func LoadPluginTypesFromDir(pluginDir string) error { if err != nil { continue } - var m pluginManifestTypes - if err := json.Unmarshal(data, &m); err != nil { - continue - } - for _, t := range m.ModuleTypes { - RegisterModuleType(t) - } - for _, t := range m.StepTypes { - // Step types share the module type registry (identified by "step." prefix). - RegisterModuleType(t) - } - for _, t := range m.ResourceTypes { - RegisterModuleType(t) - } - for _, t := range m.TriggerTypes { - RegisterTriggerType(t) - } - 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.ResourceTypes { + registerPluginManifestTypes(data) + } + return nil +} + +// LoadPluginTypesFromManifest reads a single plugin.json file at manifestPath +// and registers its declared module/step/trigger/workflow/resource types with +// the schema package. Returns an error if the file cannot be read or is not +// valid JSON; this is the explicit-path counterpart to LoadPluginTypesFromDir +// and surfaces problems instead of silently skipping them. +func LoadPluginTypesFromManifest(manifestPath string) error { + data, err := os.ReadFile(manifestPath) //nolint:gosec // G304: path is explicitly supplied by the operator + if err != nil { + return fmt.Errorf("read plugin manifest %q: %w", manifestPath, err) + } + if !registerPluginManifestTypes(data) { + return fmt.Errorf("parse plugin manifest %q: not a valid plugin.json", manifestPath) + } + return nil +} + +// registerPluginManifestTypes parses one plugin.json manifest blob and +// registers all declared types. Returns false only when the JSON itself is +// unparseable; missing or empty type fields are not an error. +func registerPluginManifestTypes(data []byte) bool { + var m pluginManifestTypes + if err := json.Unmarshal(data, &m); err != nil { + return false + } + for _, t := range m.ModuleTypes { + RegisterModuleType(t) + } + for _, t := range m.StepTypes { + // Step types share the module type registry (identified by "step." prefix). + RegisterModuleType(t) + } + for _, t := range m.ResourceTypes { + RegisterModuleType(t) + } + for _, t := range m.TriggerTypes { + RegisterTriggerType(t) + } + 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.ResourceTypes { + RegisterModuleType(t) + } + if cap.IaCProvider != nil { + for _, t := range cap.IaCProvider.ResourceTypes { RegisterModuleType(t) } - if cap.IaCProvider != nil { - for _, t := range cap.IaCProvider.ResourceTypes { - RegisterModuleType(t) - } - } - for _, t := range cap.TriggerTypes { - RegisterTriggerType(t) - } - for _, t := range cap.WorkflowHandlers { - RegisterWorkflowType(t) - } + } + for _, t := range cap.TriggerTypes { + RegisterTriggerType(t) + } + for _, t := range cap.WorkflowHandlers { + RegisterWorkflowType(t) } } } - return nil + return true } // moduleIfThen builds an if/then conditional schema for a specific module type diff --git a/schema/step_schema.go b/schema/step_schema.go index 5d003e8f..25b4342d 100644 --- a/schema/step_schema.go +++ b/schema/step_schema.go @@ -92,7 +92,6 @@ func LoadPluginStepSchemasFromDir(pluginDir string) { if err != nil { return } - reg := GetStepSchemaRegistry() for _, e := range entries { if !e.IsDir() { continue @@ -102,16 +101,34 @@ func LoadPluginStepSchemasFromDir(pluginDir string) { if err != nil { continue } - var m struct { - StepSchemas []*StepSchema `json:"stepSchemas"` - } - if err := json.Unmarshal(data, &m); err != nil { - continue - } - for _, s := range m.StepSchemas { - if s != nil && s.Type != "" { - reg.Register(s) - } + registerPluginManifestStepSchemas(data) + } +} + +// LoadPluginStepSchemasFromManifest reads a single plugin.json file and +// registers any stepSchemas it declares. Missing files or invalid JSON are +// silently ignored to match LoadPluginStepSchemasFromDir's tolerant behavior; +// callers that need strict feedback should use LoadPluginTypesFromManifest in +// parallel. +func LoadPluginStepSchemasFromManifest(manifestPath string) { + data, err := os.ReadFile(manifestPath) //nolint:gosec // G304: path is explicitly supplied by the operator + if err != nil { + return + } + registerPluginManifestStepSchemas(data) +} + +func registerPluginManifestStepSchemas(data []byte) { + var m struct { + StepSchemas []*StepSchema `json:"stepSchemas"` + } + if err := json.Unmarshal(data, &m); err != nil { + return + } + reg := GetStepSchemaRegistry() + for _, s := range m.StepSchemas { + if s != nil && s.Type != "" { + reg.Register(s) } } }