From b8aa2ff7a71cec486a1046ec4a198b8498a259ba Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sun, 14 Jun 2026 16:30:08 -0500 Subject: [PATCH 1/2] feat: add `virtwork validate` subcommand for catalog entries Validates catalog entries structurally without deploying: checks YAML parsing, service file presence, storage/service constraints, and placeholder consistency via ValidatePlaceholders. Usage: virtwork validate [entry-names...] [--catalog-dir path] Exits 0 if all pass, non-zero with per-entry errors on failure. Signed-off-by: Melvin Hillsman --- cmd/virtwork/main.go | 79 ++++++++++++++++++++++++- cmd/virtwork/main_test.go | 41 ++++++++++++- cmd/virtwork/validatee_test.go | 104 +++++++++++++++++++++++++++++++++ internal/config/config.go | 4 ++ 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 cmd/virtwork/validatee_test.go diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 5dbb9eb..990ceee 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -55,7 +55,7 @@ realistic CPU, memory, database, network, and disk I/O metrics.`, config.BindPersistentFlags(rootCmd) - rootCmd.AddCommand(newRunCmd(), newCleanupCmd(), newVersionCmd()) + rootCmd.AddCommand(newRunCmd(), newCleanupCmd(), newValidateCmd(), newVersionCmd()) return rootCmd } @@ -115,6 +115,83 @@ func newCleanupCmd() *cobra.Command { return cmd } +func newValidateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "validate [entry-names...]", + Short: "Validate catalog entries", + Long: `Check that catalog entries conform to the expected schema and structure. +Validates YAML parsing, service file presence, storage constraints, +service port ranges, role-to-service alignment, and placeholder consistency.`, + RunE: validateE, + } + + config.BindValidateFlags(cmd) + + return cmd +} + +func validateE(cmd *cobra.Command, args []string) error { + catalogDir, _ := cmd.Flags().GetString("catalog-dir") + if catalogDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolving home directory: %w", err) + } + catalogDir = home + "/.virtwork/catalog" + } + + entries, err := os.ReadDir(catalogDir) + if err != nil { + return fmt.Errorf("reading catalog directory %s: %w", catalogDir, err) + } + + var names []string + if len(args) > 0 { + names = args + } else { + for _, d := range entries { + if d.IsDir() { + names = append(names, d.Name()) + } + } + } + + if len(names) == 0 { + return fmt.Errorf("no catalog entries found in %s", catalogDir) + } + + var failed int + for _, name := range names { + entry, loadErr := workloads.LoadCatalogEntry(catalogDir, name) + if loadErr != nil { + fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %v\n", name, loadErr) + failed++ + continue + } + + errs, warnings := workloads.ValidatePlaceholders(entry) + + for _, w := range warnings { + fmt.Fprintf(cmd.OutOrStdout(), "WARN %s: %s\n", name, w) + } + + if len(errs) > 0 { + for _, e := range errs { + fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %s\n", name, e) + } + failed++ + continue + } + + fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", name) + } + + if failed > 0 { + return fmt.Errorf("%d of %d entries failed validation", failed, len(names)) + } + return nil +} + func initAuditor(cmd *cobra.Command, cfg *config.Config) (audit.Auditor, error) { noAudit, _ := cmd.Flags().GetBool("no-audit") if noAudit || !cfg.AuditEnabled { diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index a019da8..aa7b551 100644 --- a/cmd/virtwork/main_test.go +++ b/cmd/virtwork/main_test.go @@ -39,7 +39,16 @@ func newRootCmd() *cobra.Command { } config.BindCleanupFlags(cleanupCmd) - rootCmd.AddCommand(runCmd, cleanupCmd) + validateCmd := &cobra.Command{ + Use: "validate [entry-names...]", + Short: "Validate catalog entries", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + config.BindValidateFlags(validateCmd) + + rootCmd.AddCommand(runCmd, cleanupCmd, validateCmd) return rootCmd } @@ -310,3 +319,33 @@ var _ = Describe("Cleanup command flags", func() { Expect(val).To(BeTrue()) }) }) + +var _ = Describe("Validate command flags", func() { + var rootCmd *cobra.Command + + BeforeEach(func() { + rootCmd = newRootCmd() + }) + + It("should accept --catalog-dir flag", func() { + rootCmd.SetArgs([]string{"validate", "--catalog-dir", "/tmp/my-catalog"}) + Expect(rootCmd.Execute()).To(Succeed()) + + validateCmd, _, _ := rootCmd.Find([]string{"validate"}) + val, err := validateCmd.Flags().GetString("catalog-dir") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("/tmp/my-catalog")) + }) + + It("should default catalog-dir to empty string", func() { + rootCmd.SetArgs([]string{"validate"}) + Expect(rootCmd.Execute()).To(Succeed()) + + validateCmd, _, _ := rootCmd.Find([]string{"validate"}) + val, err := validateCmd.Flags().GetString("catalog-dir") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEmpty()) + }) +}) + + diff --git a/cmd/virtwork/validatee_test.go b/cmd/virtwork/validatee_test.go new file mode 100644 index 0000000..445938f --- /dev/null +++ b/cmd/virtwork/validatee_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("validateE", func() { + var catalogDir string + + writeFile := func(dir, name, content string) { + Expect(os.MkdirAll(dir, 0o750)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, name), []byte(content), 0o640)).To(Succeed()) + } + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-validate-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + It("should succeed for a valid catalog entry", func() { + entryDir := filepath.Join(catalogDir, "good") + writeFile(entryDir, "workload.yaml", "description: test\n") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + + var out bytes.Buffer + cmd := newValidateCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--catalog-dir", catalogDir}) + Expect(cmd.Execute()).To(Succeed()) + Expect(out.String()).To(ContainSubstring("good")) + Expect(out.String()).To(ContainSubstring("OK")) + }) + + It("should fail for a nonexistent catalog directory", func() { + cmd := newValidateCmd() + cmd.SetArgs([]string{"--catalog-dir", "/nonexistent/path"}) + Expect(cmd.Execute()).NotTo(Succeed()) + }) + + It("should validate only named entries when positional args given", func() { + entryDir := filepath.Join(catalogDir, "one") + writeFile(entryDir, "workload.yaml", "description: test\n") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + otherDir := filepath.Join(catalogDir, "two") + writeFile(otherDir, "workload.yaml", "description: test2\n") + writeFile(otherDir, "workload.service", "[Service]\nExecStart=/bin/test2\n") + + var out bytes.Buffer + cmd := newValidateCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--catalog-dir", catalogDir, "one"}) + Expect(cmd.Execute()).To(Succeed()) + Expect(out.String()).To(ContainSubstring("one")) + Expect(out.String()).NotTo(ContainSubstring("two")) + }) + + It("should exit non-zero when an entry fails validation", func() { + entryDir := filepath.Join(catalogDir, "bad") + Expect(os.MkdirAll(entryDir, 0o750)).To(Succeed()) + + cmd := newValidateCmd() + cmd.SetArgs([]string{"--catalog-dir", catalogDir}) + Expect(cmd.Execute()).NotTo(Succeed()) + }) + + It("should report placeholder warnings without failing", func() { + entryDir := filepath.Join(catalogDir, "warn") + writeFile(entryDir, "workload.yaml", "description: test\nparams:\n - key: unused\n type: string\n default: x\n") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + + var out bytes.Buffer + cmd := newValidateCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--catalog-dir", catalogDir}) + Expect(cmd.Execute()).To(Succeed()) + Expect(out.String()).To(ContainSubstring("unused")) + }) + + It("should fail when a placeholder has no matching param", func() { + entryDir := filepath.Join(catalogDir, "typo") + writeFile(entryDir, "workload.yaml", "description: test\n") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test --x={{oops}}\n") + + cmd := newValidateCmd() + cmd.SetArgs([]string{"--catalog-dir", catalogDir}) + Expect(cmd.Execute()).NotTo(Succeed()) + }) +}) diff --git a/internal/config/config.go b/internal/config/config.go index 521dd6d..8555c4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,6 +151,10 @@ func BindCleanupFlags(cmd *cobra.Command) { f.BoolP("yes", "y", false, "Skip confirmation prompt and proceed with cleanup") } +func BindValidateFlags(cmd *cobra.Command) { + cmd.Flags().String("catalog-dir", "", "Path to catalog directory (default ~/.virtwork/catalog)") +} + // LoadConfig loads configuration from flags, environment variables, config file, // and defaults using the Viper priority chain: flags > env > file > defaults. func LoadConfig(cmd *cobra.Command) (*Config, error) { From 3597418a51bec2931351fa50ccbca4eacf33d58e Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sun, 14 Jun 2026 18:06:59 -0500 Subject: [PATCH 2/2] fix: resolve golangci-lint errors in validate subcommand - Use wrapped static errors (err113): add ErrNoEntries and ErrValidationFailed sentinels, wrap with %w in validateE - Check fmt.Fprintf return values (errcheck): prefix with _, _ - Remove trailing blank lines in main_test.go (gci) - Break long line in validatee_test.go (golines) - Use 0o600 file permissions in test helper (gosec G306) Signed-off-by: Melvin Hillsman --- cmd/virtwork/main.go | 26 +++++++++++++------------- cmd/virtwork/main_test.go | 2 -- cmd/virtwork/validatee_test.go | 8 ++++++-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 990ceee..7f7cc15 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -28,13 +28,13 @@ import ( ) var ( - version = "" - commit = "" - date = "" - - clusterConnect = cluster.Connect - - ErrNoWorkloads = errors.New("no workloads specified: use --workloads or --from-catalog") + version = "" + commit = "" + date = "" + clusterConnect = cluster.Connect + ErrNoEntries = errors.New("no catalog entries found") + ErrNoWorkloads = errors.New("no workloads specified: use --workloads or --from-catalog") + ErrValidationFailed = errors.New("catalog validation failed") ) func main() { @@ -157,14 +157,14 @@ func validateE(cmd *cobra.Command, args []string) error { } if len(names) == 0 { - return fmt.Errorf("no catalog entries found in %s", catalogDir) + return fmt.Errorf("%s: %w", catalogDir, ErrNoEntries) } var failed int for _, name := range names { entry, loadErr := workloads.LoadCatalogEntry(catalogDir, name) if loadErr != nil { - fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %v\n", name, loadErr) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %v\n", name, loadErr) failed++ continue } @@ -172,22 +172,22 @@ func validateE(cmd *cobra.Command, args []string) error { errs, warnings := workloads.ValidatePlaceholders(entry) for _, w := range warnings { - fmt.Fprintf(cmd.OutOrStdout(), "WARN %s: %s\n", name, w) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "WARN %s: %s\n", name, w) } if len(errs) > 0 { for _, e := range errs { - fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %s\n", name, e) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %s\n", name, e) } failed++ continue } - fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", name) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", name) } if failed > 0 { - return fmt.Errorf("%d of %d entries failed validation", failed, len(names)) + return fmt.Errorf("%d of %d entries: %w", failed, len(names), ErrValidationFailed) } return nil } diff --git a/cmd/virtwork/main_test.go b/cmd/virtwork/main_test.go index aa7b551..d754a37 100644 --- a/cmd/virtwork/main_test.go +++ b/cmd/virtwork/main_test.go @@ -347,5 +347,3 @@ var _ = Describe("Validate command flags", func() { Expect(val).To(BeEmpty()) }) }) - - diff --git a/cmd/virtwork/validatee_test.go b/cmd/virtwork/validatee_test.go index 445938f..e75204a 100644 --- a/cmd/virtwork/validatee_test.go +++ b/cmd/virtwork/validatee_test.go @@ -17,7 +17,7 @@ var _ = Describe("validateE", func() { writeFile := func(dir, name, content string) { Expect(os.MkdirAll(dir, 0o750)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, name), []byte(content), 0o640)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)).To(Succeed()) } BeforeEach(func() { @@ -80,7 +80,11 @@ var _ = Describe("validateE", func() { It("should report placeholder warnings without failing", func() { entryDir := filepath.Join(catalogDir, "warn") - writeFile(entryDir, "workload.yaml", "description: test\nparams:\n - key: unused\n type: string\n default: x\n") + writeFile( + entryDir, + "workload.yaml", + "description: test\nparams:\n - key: unused\n type: string\n default: x\n", + ) writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") var out bytes.Buffer