diff --git a/cmd/wfctl/config_migrate_test.go b/cmd/wfctl/config_migrate_test.go index 6b3e6ad3..832dd6f1 100644 --- a/cmd/wfctl/config_migrate_test.go +++ b/cmd/wfctl/config_migrate_test.go @@ -48,8 +48,8 @@ func TestConfigMigrate_NoArgs_PrintsUsage(t *testing.T) { // TestConfigMigrate_DefaultWriterIsStderr verifies that the deprecation banner // defaults to os.Stderr, so future changes that accidentally redirect it are caught. func TestConfigMigrate_DefaultWriterIsStderr(t *testing.T) { - if migrateDeprecationWriter != os.Stderr { - t.Errorf("migrateDeprecationWriter default should be os.Stderr, got %T", migrateDeprecationWriter) + if migrateDeprecationWriter != defaultMigrateDeprecationWriter { + t.Errorf("migrateDeprecationWriter default should be defaultMigrateDeprecationWriter, got %T", migrateDeprecationWriter) } } @@ -118,6 +118,19 @@ func TestConfigCommand_DispatchesMigrate(t *testing.T) { } } +func TestConfigCommand_EmbeddedCLIWiresConfigCommand(t *testing.T) { + embedded := string(wfctlConfigBytes) + for _, want := range []string{ + "name: config", + "cmd-config:", + "command: config", + } { + if !strings.Contains(embedded, want) { + t.Fatalf("embedded wfctl config must wire config command, missing %q", want) + } + } +} + // TestConfigCommand_UnknownSubcommand verifies that wfctl config with an // unknown sub-subcommand returns a clear error. func TestConfigCommand_UnknownSubcommand(t *testing.T) { @@ -129,3 +142,231 @@ func TestConfigCommand_UnknownSubcommand(t *testing.T) { t.Errorf("expected 'unknown' in error, got: %v", err) } } + +func TestConfigValidateAcceptsWfctlManifestAndLockfile(t *testing.T) { + dir := t.TempDir() + manifest := filepath.Join(dir, "wfctl.yaml") + lockfile := filepath.Join(dir, ".wfctl-lock.yaml") + if err := os.WriteFile(manifest, []byte(`version: 1 +plugins: + - name: workflow-plugin-digitalocean + version: v1.0.13 + source: github.com/GoCodeAlone/workflow-plugin-digitalocean +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockfile, []byte(`version: 1 +generated_at: 2026-05-14T00:00:00Z +plugins: + workflow-plugin-digitalocean: + version: v1.0.13 + source: github.com/GoCodeAlone/workflow-plugin-digitalocean + platforms: + darwin/arm64: + url: https://example.invalid/workflow-plugin-digitalocean_Darwin_arm64.tar.gz + sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +`), 0o600); err != nil { + t.Fatal(err) + } + if err := runConfig([]string{"validate", "--manifest", manifest, "--lock-file", lockfile}); err != nil { + t.Fatalf("wfctl config validate failed: %v", err) + } +} + +func TestConfigValidateAcceptsPositionalManifestAndWarnsOnMissingDefaultLock(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.WriteFile("wfctl.yaml", []byte(`version: 1 +plugins: + - name: workflow-plugin-digitalocean + version: v1.0.13 +`), 0o600); err != nil { + t.Fatal(err) + } + stderr := captureConfigValidateStderr(t, func() { + if err := runConfigValidate([]string{"wfctl.yaml"}); err != nil { + t.Fatalf("config validate: %v", err) + } + }) + if !strings.Contains(stderr, "lockfile not found") { + t.Fatalf("stderr = %q, want missing lockfile warning", stderr) + } +} + +func TestConfigValidateRejectsTooManyPositionals(t *testing.T) { + if err := runConfigValidate([]string{"one.yaml", "two.yaml"}); err == nil { + t.Fatal("expected too many positional args to fail") + } +} + +func TestConfigValidateRejectsRuntimeWorkflowConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(path, []byte(minimalConfig), 0o600); err != nil { + t.Fatal(err) + } + err := runConfig([]string{"validate", path, "--skip-lock"}) + if err == nil { + t.Fatal("expected workflow runtime config to be rejected by wfctl config validate") + } + if !strings.Contains(err.Error(), "not a wfctl project manifest") { + t.Fatalf("error = %v, want project manifest guidance", err) + } +} + +func TestValidateWfctlManifestFileReportsPluginProblems(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wfctl.yaml") + if err := os.WriteFile(path, []byte(`version: 2 +plugins: + - name: workflow-plugin-foo + version: "" + auth: {} + verify: {} + - name: workflow-plugin-foo + version: v1.0.0 + - version: v1.0.0 +`), 0o600); err != nil { + t.Fatal(err) + } + err := validateWfctlManifestFile(path) + if err == nil { + t.Fatal("expected invalid manifest to fail") + } + for _, want := range []string{"version: got 2 want 1", "duplicated", "version is required", "auth.env", "verify.identity", "name is required"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, missing %q", err, want) + } + } +} + +func TestValidateWfctlLockfileReportsPlatformProblems(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl-lock.yaml") + if err := os.WriteFile(path, []byte(`version: 2 +plugins: + workflow-plugin-foo: + version: "" + platforms: + "": + url: not a url + sha256: abc +`), 0o600); err != nil { + t.Fatal(err) + } + err := validateWfctlLockfile(path) + if err == nil { + t.Fatal("expected invalid lockfile to fail") + } + for _, want := range []string{"version: got 2 want 1", "version is required", "empty platform", "url is invalid", "got length 3 want 64"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, missing %q", err, want) + } + } +} + +func TestValidateWfctlLockfilePreservesExplicitMissingLockError(t *testing.T) { + err := validateWfctlLockfile(filepath.Join(t.TempDir(), ".wfctl-lock.yaml")) + if !os.IsNotExist(err) { + t.Fatalf("error = %v, want os.ErrNotExist", err) + } +} + +func TestRunValidateRejectsWfctlManifestWithGuidance(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wfctl.yaml") + if err := os.WriteFile(path, []byte(`version: 1 +plugins: + - name: workflow-plugin-digitalocean + version: v1.0.13 +`), 0o600); err != nil { + t.Fatal(err) + } + err := runValidate([]string{path}) + if err == nil { + t.Fatal("expected wfctl validate to reject wfctl.yaml") + } + if !strings.Contains(err.Error(), "wfctl config validate") { + t.Fatalf("error = %v, want guidance to wfctl config validate", err) + } +} + +func TestIsLikelyWfctlProjectManifestClassifiesOnlyCanonicalProjectFiles(t *testing.T) { + dir := t.TempDir() + wfctl := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(wfctl, []byte(`version: 1 +plugins: [] +`), 0o600); err != nil { + t.Fatal(err) + } + if !isLikelyWfctlProjectManifest(wfctl) { + t.Fatal(".wfctl.yaml with plugins should be classified as wfctl project manifest") + } + runtime := filepath.Join(dir, "wfctl.yaml") + if err := os.WriteFile(runtime, []byte(`version: 1 +plugins: [] +modules: [] +`), 0o600); err != nil { + t.Fatal(err) + } + if isLikelyWfctlProjectManifest(runtime) { + t.Fatal("wfctl.yaml with runtime keys must not be classified as project manifest") + } + other := filepath.Join(dir, "other.yaml") + if err := os.WriteFile(other, []byte(`version: 1 +plugins: [] +`), 0o600); err != nil { + t.Fatal(err) + } + if isLikelyWfctlProjectManifest(other) { + t.Fatal("non-canonical filename must not be classified as project manifest") + } + bad := filepath.Join(dir, "bad.wfctl.yaml") + if err := os.WriteFile(bad, []byte("plugins: ["), 0o600); err != nil { + t.Fatal(err) + } + if isLikelyWfctlProjectManifest(bad) { + t.Fatal("malformed yaml must not be classified as project manifest") + } +} + +func TestRunValidateDoesNotMisclassifyOtherPluginsYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "buf.gen.yaml") + if err := os.WriteFile(path, []byte(`version: v2 +plugins: + - local: protoc-gen-go + out: gen/go +`), 0o600); err != nil { + t.Fatal(err) + } + err := runValidate([]string{path}) + if err == nil { + t.Fatal("expected non-workflow plugins YAML to fail workflow validation") + } + if strings.Contains(err.Error(), "wfctl config validate") { + t.Fatalf("error = %v, should not classify buf.gen.yaml as wfctl project manifest", err) + } +} + +func captureConfigValidateStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stderr + os.Stderr = w + defer func() { + os.Stderr = orig + }() + fn() + if err := w.Close(); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + return buf.String() +} diff --git a/cmd/wfctl/config_validate.go b/cmd/wfctl/config_validate.go new file mode 100644 index 00000000..92b5374a --- /dev/null +++ b/cmd/wfctl/config_validate.go @@ -0,0 +1,196 @@ +package main + +import ( + "encoding/hex" + "errors" + "flag" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "gopkg.in/yaml.v3" +) + +func runConfigValidate(args []string) error { + fs := flag.NewFlagSet("config validate", flag.ContinueOnError) + manifestPath := fs.String("manifest", wfctlManifestPath, "Path to wfctl.yaml project manifest") + lockPath := fs.String("lock-file", wfctlLockPath, "Path to .wfctl-lock.yaml") + skipLock := fs.Bool("skip-lock", false, "Skip lockfile validation") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl config validate [options] [wfctl.yaml] + +Validate wfctl project configuration files. Use "wfctl validate" for workflow +runtime configs such as workflow.yaml. + +Options: +`) + fs.PrintDefaults() + } + args = reorderFlags(args) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() > 1 { + return fmt.Errorf("expected at most one positional manifest path") + } + if fs.NArg() == 1 { + *manifestPath = fs.Arg(0) + } + lockFlagExplicit := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "lock-file" { + lockFlagExplicit = true + } + }) + + if err := validateWfctlManifestFile(*manifestPath); err != nil { + return err + } + fmt.Printf(" PASS %s (wfctl project manifest)\n", *manifestPath) + + if *skipLock { + return nil + } + if err := validateWfctlLockfile(*lockPath); err != nil { + if errors.Is(err, os.ErrNotExist) && !lockFlagExplicit { + fmt.Fprintf(os.Stderr, " WARN %s: lockfile not found; run 'wfctl plugin lock' when plugins are declared\n", *lockPath) + return nil + } + return err + } + fmt.Printf(" PASS %s (wfctl plugin lockfile)\n", *lockPath) + return nil +} + +func validateWfctlManifestFile(path string) error { + raw, err := readYAMLMap(path) + if err != nil { + return err + } + if _, hasVersion := raw["version"]; !hasVersion { + if _, hasPlugins := raw["plugins"]; !hasPlugins { + return fmt.Errorf("%s is not a wfctl project manifest; use 'wfctl validate' for workflow runtime configs", path) + } + } + manifest, err := config.LoadWfctlManifest(path) + if err != nil { + return err + } + var errs []error + if manifest.Version != 1 { + errs = append(errs, fmt.Errorf("version: got %d want 1", manifest.Version)) + } + seen := map[string]struct{}{} + for i, plugin := range manifest.Plugins { + name := strings.TrimSpace(plugin.Name) + if name == "" { + errs = append(errs, fmt.Errorf("plugins[%d].name is required", i)) + } else if _, ok := seen[name]; ok { + errs = append(errs, fmt.Errorf("plugins[%d].name %q is duplicated", i, name)) + } + seen[name] = struct{}{} + if strings.TrimSpace(plugin.Version) == "" { + errs = append(errs, fmt.Errorf("plugins[%d].version is required", i)) + } + if plugin.Auth != nil && strings.TrimSpace(plugin.Auth.Env) == "" { + errs = append(errs, fmt.Errorf("plugins[%d].auth.env is required when auth is declared", i)) + } + if plugin.Verify != nil && strings.TrimSpace(plugin.Verify.Identity) == "" { + errs = append(errs, fmt.Errorf("plugins[%d].verify.identity is required when verify is declared", i)) + } + } + if len(errs) > 0 { + return fmt.Errorf("invalid wfctl manifest %s: %w", path, errors.Join(errs...)) + } + return nil +} + +func validateWfctlLockfile(path string) error { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return os.ErrNotExist + } + return fmt.Errorf("stat lockfile %s: %w", path, err) + } + lockfile, err := config.LoadWfctlLockfile(path) + if err != nil { + return err + } + var errs []error + if lockfile.Version != 1 { + errs = append(errs, fmt.Errorf("version: got %d want 1", lockfile.Version)) + } + for name, plugin := range lockfile.Plugins { + if strings.TrimSpace(name) == "" { + errs = append(errs, fmt.Errorf("plugins contains an empty plugin key")) + } + if strings.TrimSpace(plugin.Version) == "" { + errs = append(errs, fmt.Errorf("plugins[%s].version is required", name)) + } + for platform, artifact := range plugin.Platforms { + if strings.TrimSpace(platform) == "" { + errs = append(errs, fmt.Errorf("plugins[%s].platforms contains an empty platform key", name)) + } + if _, err := url.ParseRequestURI(artifact.URL); err != nil { + errs = append(errs, fmt.Errorf("plugins[%s].platforms[%s].url is invalid: %w", name, platform, err)) + } + if err := validateSHA256Hex(artifact.SHA256); err != nil { + errs = append(errs, fmt.Errorf("plugins[%s].platforms[%s].sha256: %w", name, platform, err)) + } + } + } + if len(errs) > 0 { + return fmt.Errorf("invalid wfctl lockfile %s: %w", path, errors.Join(errs...)) + } + return nil +} + +func readYAMLMap(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + out := map[string]any{} + if err := yaml.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return out, nil +} + +func isLikelyWfctlProjectManifest(path string) bool { + switch filepath.Base(path) { + case wfctlManifestPath, ".wfctl.yaml": + default: + return false + } + raw, err := readYAMLMap(path) + if err != nil { + return false + } + if _, hasPlugins := raw["plugins"]; !hasPlugins { + return false + } + for _, runtimeKey := range []string{"modules", "workflows", "triggers", "pipelines", "services"} { + if _, ok := raw[runtimeKey]; ok { + return false + } + } + return true +} + +func validateSHA256Hex(value string) error { + if len(value) != 64 { + return fmt.Errorf("got length %d want 64", len(value)) + } + decoded, err := hex.DecodeString(value) + if err != nil { + return err + } + if len(decoded) != 32 { + return fmt.Errorf("decoded length got %d want 32", len(decoded)) + } + return nil +} diff --git a/cmd/wfctl/infra_e2e_test.go b/cmd/wfctl/infra_e2e_test.go index f067e558..28796590 100644 --- a/cmd/wfctl/infra_e2e_test.go +++ b/cmd/wfctl/infra_e2e_test.go @@ -39,6 +39,9 @@ func TestInfraMultiEnv_E2E(t *testing.T) { t.Run("staging plan excludes dns", func(t *testing.T) { out, runErr := exec.Command(wfctlPath, "infra", "plan", "--env", "staging", "--config", fixture).CombinedOutput() if runErr != nil { + if strings.Contains(string(out), `no plugin found for IaC provider "digitalocean"`) { + t.Skipf("DigitalOcean IaC plugin not installed for subprocess wfctl: %s", out) + } t.Fatalf("wfctl infra plan --env staging: %v\n%s", runErr, out) } output := string(out) @@ -56,6 +59,9 @@ func TestInfraMultiEnv_E2E(t *testing.T) { t.Run("prod plan includes dns with large db", func(t *testing.T) { out, runErr := exec.Command(wfctlPath, "infra", "plan", "--env", "prod", "--config", fixture).CombinedOutput() if runErr != nil { + if strings.Contains(string(out), `no plugin found for IaC provider "digitalocean"`) { + t.Skipf("DigitalOcean IaC plugin not installed for subprocess wfctl: %s", out) + } t.Fatalf("wfctl infra plan --env prod: %v\n%s", runErr, out) } output := string(out) diff --git a/cmd/wfctl/migrate.go b/cmd/wfctl/migrate.go index 9892e6e3..946ca76c 100644 --- a/cmd/wfctl/migrate.go +++ b/cmd/wfctl/migrate.go @@ -30,6 +30,7 @@ func runConfig(args []string) error { Manage engine configuration. Subcommands: + validate Validate wfctl.yaml and .wfctl-lock.yaml project config files migrate Manage engine config database schema migrations (replaces the deprecated wfctl migrate command) @@ -37,16 +38,21 @@ Subcommands: return fmt.Errorf("missing or unknown subcommand") } switch args[0] { + case "validate": + return runConfigValidate(args[1:]) case "migrate": return runConfigMigrate(args[1:]) default: - return fmt.Errorf("unknown wfctl config subcommand %q (available: migrate)", args[0]) + return fmt.Errorf("unknown wfctl config subcommand %q (available: validate, migrate)", args[0]) } } // migrateDeprecationWriter is the io.Writer that receives the deprecation // banner from runMigrateDeprecated. Defaults to os.Stderr; overridden in tests. -var migrateDeprecationWriter io.Writer = os.Stderr +var ( + defaultMigrateDeprecationWriter io.Writer = os.Stderr + migrateDeprecationWriter io.Writer = defaultMigrateDeprecationWriter +) // runMigrateDeprecated is the legacy wfctl migrate dispatcher. It prints a // one-time deprecation notice then delegates to runConfigMigrate. diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 450725b7..d89735f0 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -110,14 +110,28 @@ Options: } if failed > 0 { + if total == 1 && len(errors) == 1 { + return fmt.Errorf("%d config(s) failed validation: %s", failed, indentErrorMessage(errors[0])) + } return fmt.Errorf("%d config(s) failed validation", failed) } return nil } +func indentErrorMessage(message string) string { + lines := strings.Split(message, "\n") + if len(lines) == 0 { + return message + } + return strings.TrimSpace(lines[len(lines)-1]) +} + func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints bool) error { // Read raw YAML to extract imports list for verbose feedback. imports := extractImports(cfgPath) + if isLikelyWfctlProjectManifest(cfgPath) { + return fmt.Errorf("%s is a wfctl project manifest; use 'wfctl config validate %s' instead", cfgPath, cfgPath) + } cfg, err := config.LoadFromFile(cfgPath) if err != nil { @@ -278,6 +292,8 @@ func reorderFlags(args []string) []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, } for i := 0; i < len(args); i++ { diff --git a/cmd/wfctl/wfctl.yaml b/cmd/wfctl/wfctl.yaml index 87d4c35f..16671a80 100644 --- a/cmd/wfctl/wfctl.yaml +++ b/cmd/wfctl/wfctl.yaml @@ -27,6 +27,8 @@ workflows: description: Analyze config and report infrastructure requirements - name: migrate description: Manage database schema migrations + - name: config + description: Manage wfctl project configuration - name: migrations description: Validate and guard migration workflows - name: build-ui @@ -141,6 +143,10 @@ pipelines: trigger: {type: cli, config: {command: migrate}} steps: - {name: run, type: step.cli_invoke, config: {command: migrate}} + cmd-config: + trigger: {type: cli, config: {command: config}} + steps: + - {name: run, type: step.cli_invoke, config: {command: config}} cmd-migrations: trigger: {type: cli, config: {command: migrations}} steps: diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 1f47ea34..67c2caa7 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -437,6 +437,9 @@ wfctl validate --skip-unknown-types example/*.yaml wfctl validate --plugin-dir data/plugins config.yaml ``` +Use `wfctl config validate` for `wfctl.yaml` and `.wfctl-lock.yaml`; this +command validates runtime Workflow configs such as `workflow.yaml`. + When validating multiple files, a summary is printed: ``` PASS example/api-server-config.yaml (5 modules, 3 workflows, 2 triggers) @@ -947,6 +950,47 @@ wfctl manifest -name my-service config.yaml --- +### `config` + +Manage wfctl project configuration. + +``` +wfctl config [options] +``` + +#### Subcommands + +| Subcommand | Description | +|------------|-------------| +| `validate` | Validate `wfctl.yaml` and `.wfctl-lock.yaml` project config files | +| `migrate` | Manage engine config database schema migrations | + +#### `config validate` + +Validate the human-edited `wfctl.yaml` plugin manifest and, unless skipped, the +machine-generated `.wfctl-lock.yaml` plugin lockfile. + +``` +wfctl config validate [options] [wfctl.yaml] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--manifest` | `wfctl.yaml` | Path to the wfctl project manifest | +| `--lock-file` | `.wfctl-lock.yaml` | Path to the plugin lockfile | +| `--skip-lock` | `false` | Skip lockfile validation | + +**Examples:** + +```bash +wfctl config validate +wfctl config validate wfctl.yaml +wfctl config validate --manifest wfctl.yaml --lock-file .wfctl-lock.yaml +wfctl config validate --skip-lock +``` + +--- + ### `migrate` Manage database schema migrations.