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
2 changes: 1 addition & 1 deletion cmd/wfctl/legacy_aws_types_removed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/wfctl/legacy_do_types_removed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/wfctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
27 changes: 21 additions & 6 deletions cmd/wfctl/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] <config.yaml> [config2.yaml ...]

Expand All @@ -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:
`)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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())
Expand Down Expand Up @@ -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], "-") {
Expand Down
208 changes: 208 additions & 0 deletions cmd/wfctl/validate_local_manifests_test.go
Original file line number Diff line number Diff line change
@@ -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 <path>: 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 <file>: %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 <dir>: %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 <cfgDir>/<name>/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 <cfgDir>/providers/<name>/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")
}
}
Loading
Loading