diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index 5dbb9eb..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() { @@ -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("%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) + 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: %w", failed, len(names), ErrValidationFailed) + } + 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..d754a37 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,31 @@ 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..e75204a --- /dev/null +++ b/cmd/virtwork/validatee_test.go @@ -0,0 +1,108 @@ +// 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), 0o600)).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) {