diff --git a/cmd/virtwork/main.go b/cmd/virtwork/main.go index fbd5c6d..5dbb9eb 100644 --- a/cmd/virtwork/main.go +++ b/cmd/virtwork/main.go @@ -33,6 +33,8 @@ var ( date = "" clusterConnect = cluster.Connect + + ErrNoWorkloads = errors.New("no workloads specified: use --workloads or --from-catalog") ) func main() { @@ -134,8 +136,20 @@ func runE(cmd *cobra.Command, args []string) error { } workloadNames, _ := cmd.Flags().GetStringSlice("workloads") - if err := workloads.ValidateWorkloadNames(workloadNames); err != nil { - return err + fromCatalog := cfg.FromCatalog + + if len(fromCatalog) > 0 && !cmd.Flags().Changed("workloads") { + workloadNames = nil + } + + if len(workloadNames) > 0 { + if err := workloads.ValidateWorkloadNames(workloadNames); err != nil { + return err + } + } + + if len(workloadNames) == 0 && len(fromCatalog) == 0 { + return ErrNoWorkloads } //nolint:dupl diff --git a/cmd/virtwork/rune_test.go b/cmd/virtwork/rune_test.go index ea6e123..86d8e83 100644 --- a/cmd/virtwork/rune_test.go +++ b/cmd/virtwork/rune_test.go @@ -63,6 +63,78 @@ var _ = Describe("runE", func() { }) }) + Context("catalog workloads", func() { + var catalogDir string + + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-cli-catalog-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + It("runs only catalog entry when --from-catalog set without --workloads", func() { + entryDir := filepath.Join(catalogDir, "my-svc") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/true\n") + + rootCmd := newRootCmd() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{ + "run", "--dry-run", "--no-audit", + "--catalog-dir", catalogDir, + "--from-catalog", "my-svc", + }) + Expect(rootCmd.Execute()).To(Succeed()) + + output := buf.String() + Expect(output).To(ContainSubstring("my-svc")) + Expect(output).NotTo(ContainSubstring("virtwork-cpu")) + }) + + It("runs both catalog and built-in when both flags set", func() { + entryDir := filepath.Join(catalogDir, "my-svc") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/true\n") + + rootCmd := newRootCmd() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{ + "run", "--dry-run", "--no-audit", + "--workloads", "cpu", + "--catalog-dir", catalogDir, + "--from-catalog", "my-svc", + }) + Expect(rootCmd.Execute()).To(Succeed()) + + output := buf.String() + Expect(output).To(ContainSubstring("virtwork-cpu")) + Expect(output).To(ContainSubstring("my-svc")) + }) + + It("returns error for invalid catalog entry", func() { + rootCmd := newRootCmd() + rootCmd.SetArgs([]string{ + "run", "--dry-run", "--no-audit", + "--catalog-dir", catalogDir, + "--from-catalog", "nonexistent", + }) + err := rootCmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("nonexistent")) + }) + }) + Context("dry-run mode", func() { It("succeeds without a cluster connection", func() { rootCmd := newRootCmd() diff --git a/internal/config/config.go b/internal/config/config.go index a3c3a6e..521dd6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,8 @@ type Config struct { AuditEnabled bool `mapstructure:"audit"` AuditDBPath string `mapstructure:"audit-db"` VMConcurrency int `mapstructure:"vm-concurrency"` + CatalogDir string `mapstructure:"catalog-dir"` + FromCatalog []string `mapstructure:"from-catalog"` } // SetDefaults registers Viper defaults. @@ -136,6 +138,8 @@ func BindRunFlags(cmd *cobra.Command, defaultWorkloads []string) { f.StringSlice("ssh-key-file", nil, "SSH key file path (repeatable)") f.Int("vm-concurrency", constants.DefaultVMConcurrency, "Max concurrent VM creation operations") f.String("params", "", "Per-workload params (comma-separated workload.key=value pairs)") + f.String("catalog-dir", "", "Path to catalog directory (default ~/.virtwork/catalog)") + f.StringSlice("from-catalog", nil, "Catalog entries to load (comma-separated)") } // BindCleanupFlags registers flags specific to the "cleanup" subcommand. @@ -222,6 +226,23 @@ func LoadConfig(cmd *cobra.Command) (*Config, error) { cfg.AuditDBPath = v.GetString("audit-db") cfg.VMConcurrency = v.GetInt("vm-concurrency") + // Catalog settings + bindFlagIfSet(v, cmd, "catalog-dir") + cfg.CatalogDir = v.GetString("catalog-dir") + if cfg.CatalogDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolving home directory for catalog-dir: %w", err) + } + cfg.CatalogDir = filepath.Join(home, ".virtwork", "catalog") + } + + if cmd.Flags().Changed("from-catalog") { + cfg.FromCatalog, _ = cmd.Flags().GetStringSlice("from-catalog") + } else { + cfg.FromCatalog = v.GetStringSlice("from-catalog") + } + // Handle SSH authorized keys: CLI flags, env var (comma-split), or YAML list sshKeys, err := resolveSSHKeys(v, cmd) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e95510c..f4fe7a6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -809,3 +809,75 @@ workloads: }) }) }) + +var _ = Describe("Catalog config", func() { + var cmd *cobra.Command + + BeforeEach(func() { + for _, env := range os.Environ() { + if strings.HasPrefix(env, "VIRTWORK_") { + _ = os.Unsetenv(strings.Split(env, "=")[0]) + } + } + cmd = newTestCommand() + }) + + It("should default CatalogDir to ~/.virtwork/catalog", func() { + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + home, err := os.UserHomeDir() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.CatalogDir).To(Equal(filepath.Join(home, ".virtwork", "catalog"))) + }) + + It("should accept --catalog-dir flag", func() { + Expect(cmd.Flags().Set("catalog-dir", "/tmp/my-catalog")).To(Succeed()) + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.CatalogDir).To(Equal("/tmp/my-catalog")) + }) + + It("should accept VIRTWORK_CATALOG_DIR env var", func() { + _ = os.Setenv("VIRTWORK_CATALOG_DIR", "/opt/catalog") + defer func() { + _ = os.Unsetenv("VIRTWORK_CATALOG_DIR") + }() + + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.CatalogDir).To(Equal("/opt/catalog")) + }) + + It("should default FromCatalog to empty", func() { + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.FromCatalog).To(BeEmpty()) + }) + + It("should accept --from-catalog flag", func() { + Expect(cmd.Flags().Set("from-catalog", "my-stress,my-bench")).To(Succeed()) + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.FromCatalog).To(ConsistOf("my-stress", "my-bench")) + }) + + It("should load from-catalog from YAML config", func() { + tmpDir, err := os.MkdirTemp("", "virtwork-config-test-*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + path := writeConfigFile(tmpDir, ` +from-catalog: + - entry-a + - entry-b +catalog-dir: /custom/catalog +`) + Expect(cmd.Flags().Set("config", path)).To(Succeed()) + cfg, err := config.LoadConfig(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.FromCatalog).To(ConsistOf("entry-a", "entry-b")) + Expect(cfg.CatalogDir).To(Equal("/custom/catalog")) + }) +}) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 3971a46..9e51e71 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -27,7 +27,10 @@ import ( "github.com/opdev/virtwork/internal/workloads" ) -var ErrReadinessCheck = errors.New("readiness check failed") +var ( + ErrReadinessCheck = errors.New("readiness check failed") + ErrCatalogNameConflict = errors.New("catalog entry conflicts with built-in workload name") +) // RunOrchestrator coordinates the "run" workflow: planning VMs, creating // resources, and waiting for readiness. Dependencies are injected at @@ -75,10 +78,33 @@ func (ro *RunOrchestrator) Run( workloads.WithNamespace(cfg.Namespace), workloads.WithSSHCredentials(cfg.SSHUser, cfg.SSHPassword, cfg.SSHAuthorizedKeys), workloads.WithDataDiskSize(cfg.DataDiskSize), + workloads.WithCatalogDir(cfg.CatalogDir), } + for _, entryName := range cfg.FromCatalog { + if _, exists := registry[entryName]; exists { + return nil, fmt.Errorf( + "catalog entry %q: %w", + entryName, + ErrCatalogNameConflict, + ) + } + entry, err := workloads.LoadCatalogEntry(cfg.CatalogDir, entryName) + if err != nil { + return nil, fmt.Errorf("loading catalog entry %q: %w", entryName, err) + } + registry[entryName] = workloads.RegistryEntry{ + Factory: entry.Factory(), + ParamSchema: entry.Schema(), + } + } + + allWorkloadNames := make([]string, 0, len(workloadNames)+len(cfg.FromCatalog)) + allWorkloadNames = append(allWorkloadNames, workloadNames...) + allWorkloadNames = append(allWorkloadNames, cfg.FromCatalog...) + plans, vmNames, workloadInstances, auditWorkloadIDs, err := ro.planVMs( - ctx, execID, runID, workloadNames, vmCountFlag, registry, registryOpts, + ctx, execID, runID, allWorkloadNames, vmCountFlag, registry, registryOpts, ) if err != nil { return nil, err @@ -89,7 +115,7 @@ func (ro *RunOrchestrator) Run( Message: fmt.Sprintf( "Planned %d VMs across %d workloads", len(plans), - len(workloadNames), + len(allWorkloadNames), ), }); err != nil { ro.logger.Warn( diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 0688551..d2fa917 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "fmt" + "os" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -353,5 +355,108 @@ var _ = Describe("RunOrchestrator", func() { Expect(result.RunID).To(Equal("expected-run-id")) }) }) + + Context("catalog workloads", func() { + var catalogDir string + + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-orch-catalog-*") + Expect(err).NotTo(HaveOccurred()) + + cfg.DryRun = true + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + It("should plan VMs for a single-role catalog entry", func() { + entryDir := filepath.Join(catalogDir, "my-stress") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/usr/bin/stress-ng\n") + + cfg.CatalogDir = catalogDir + cfg.FromCatalog = []string{"my-stress"} + + logger := logging.NewLogger(buf, false) + ro := orchestrator.NewRunOrchestrator(logger, nil, cfg, auditor, buf) + + result, err := ro.Run(ctx, 0, "cat-run", nil, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(result.VMCount).To(Equal(1)) + Expect(buf.String()).To(ContainSubstring("my-stress")) + }) + + It("should plan VMs for catalog + built-in workloads together", func() { + entryDir := filepath.Join(catalogDir, "my-stress") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/usr/bin/stress-ng\n") + + cfg.CatalogDir = catalogDir + cfg.FromCatalog = []string{"my-stress"} + + logger := logging.NewLogger(buf, false) + ro := orchestrator.NewRunOrchestrator(logger, nil, cfg, auditor, buf) + + result, err := ro.Run(ctx, 0, "cat-run", []string{"cpu"}, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(result.VMCount).To(Equal(2)) + }) + + It("should return error when catalog entry conflicts with built-in name", func() { + entryDir := filepath.Join(catalogDir, "cpu") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/fake\n") + + cfg.CatalogDir = catalogDir + cfg.FromCatalog = []string{"cpu"} + + logger := logging.NewLogger(buf, false) + ro := orchestrator.NewRunOrchestrator(logger, nil, cfg, auditor, buf) + + _, err := ro.Run(ctx, 0, "cat-run", nil, 1) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("conflicts with built-in workload name")) + }) + + It("should return error for invalid catalog entry", func() { + cfg.CatalogDir = catalogDir + cfg.FromCatalog = []string{"nonexistent"} + + logger := logging.NewLogger(buf, false) + ro := orchestrator.NewRunOrchestrator(logger, nil, cfg, auditor, buf) + + _, err := ro.Run(ctx, 0, "cat-run", nil, 1) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("nonexistent")) + }) + + It("should plan VMs for a multi-role catalog entry", func() { + entryDir := filepath.Join(catalogDir, "my-bench") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/c\n") + + cfg.CatalogDir = catalogDir + cfg.FromCatalog = []string{"my-bench"} + + logger := logging.NewLogger(buf, false) + ro := orchestrator.NewRunOrchestrator(logger, nil, cfg, auditor, buf) + + result, err := ro.Run(ctx, 0, "cat-run", nil, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(result.VMCount).To(Equal(3)) + }) + }) }) }) diff --git a/internal/workloads/catalog.go b/internal/workloads/catalog.go new file mode 100644 index 0000000..c310c20 --- /dev/null +++ b/internal/workloads/catalog.go @@ -0,0 +1,183 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/opdev/virtwork/internal/config" +) + +var ( + ErrCatalogEntryNotFound = errors.New("catalog entry not found") + ErrCatalogNoServices = errors.New("catalog entry has no .service files") + ErrCatalogManifestRequired = errors.New("workload.yaml is required for multi-role catalog entries") + ErrCatalogMissingRoleService = errors.New("missing .service file for declared role") +) + +// RoleDefinition declares a role and its default VM count in a catalog manifest. +type RoleDefinition struct { + Name string `yaml:"name"` + VMCount int `yaml:"vm-count"` +} + +// catalogParamDef mirrors ParamDef for YAML unmarshaling with string type names. +type catalogParamDef struct { + Key string `yaml:"key"` + Type string `yaml:"type"` + Default string `yaml:"default"` + Desc string `yaml:"desc"` +} + +// CatalogManifest holds the parsed workload.yaml from a catalog entry. +type CatalogManifest struct { + Description string `yaml:"description"` + Packages []string `yaml:"packages"` + Params []catalogParamDef `yaml:"params"` + Roles []RoleDefinition `yaml:"roles"` +} + +// CatalogEntry represents a loaded catalog workload entry. +type CatalogEntry struct { + Name string + Dir string + Manifest CatalogManifest + ServiceFiles map[string]string +} + +// IsMultiRole returns true if the entry declares multiple roles. +func (e *CatalogEntry) IsMultiRole() bool { + return len(e.Manifest.Roles) > 1 +} + +// Schema returns the ParamSchema derived from the manifest's param declarations. +func (e *CatalogEntry) Schema() ParamSchema { + if len(e.Manifest.Params) == 0 { + return nil + } + schema := make(ParamSchema, len(e.Manifest.Params)) + for i, p := range e.Manifest.Params { + schema[i] = ParamDef{ + Key: p.Key, + Type: parseParamType(p.Type), + Default: p.Default, + Desc: p.Desc, + } + } + return schema +} + +func parseParamType(s string) ParamType { + switch strings.ToLower(s) { + case "int": + return ParamInt + case "bool": + return ParamBool + case "list": + return ParamList + case "dict": + return ParamDict + default: + return ParamString + } +} + +// Factory returns a WorkloadFactory for this catalog entry. Single-role entries +// produce GenericWorkload instances; multi-role entries produce GenericMultiWorkload. +func (e *CatalogEntry) Factory() WorkloadFactory { + entry := e + if entry.IsMultiRole() { + return func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { + return NewGenericMultiWorkload(cfg, entry, opts.Namespace, + opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) + } + } + return func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { + return NewGenericWorkload(cfg, entry, + opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) + } +} + +// LoadCatalogEntry reads a catalog entry from catalogDir/entryName. It parses +// workload.yaml (if present) and discovers .service files. For multi-role entries, +// service files are mapped to roles by filename (e.g., server.service → "server"). +func LoadCatalogEntry(catalogDir, entryName string) (*CatalogEntry, error) { + entryDir := filepath.Join(catalogDir, entryName) + info, err := os.Stat(entryDir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("catalog entry %q: %w", entryName, ErrCatalogEntryNotFound) + } + + entry := &CatalogEntry{ + Name: entryName, + Dir: entryDir, + ServiceFiles: make(map[string]string), + } + + manifestPath := filepath.Join(entryDir, "workload.yaml") + //nolint:gosec // catalog path is user-supplied intentionally + if data, err := os.ReadFile(manifestPath); err == nil { + if err := yaml.Unmarshal(data, &entry.Manifest); err != nil { + return nil, fmt.Errorf("parsing workload.yaml for %q: %w", entryName, err) + } + } + + serviceFiles, err := filepath.Glob(filepath.Join(entryDir, "*.service")) + if err != nil { + return nil, fmt.Errorf("discovering service files for %q: %w", entryName, err) + } + if len(serviceFiles) == 0 { + return nil, fmt.Errorf("catalog entry %q: %w", entryName, ErrCatalogNoServices) + } + + sort.Strings(serviceFiles) + + if entry.IsMultiRole() { + return loadMultiRoleServices(entry, serviceFiles) + } + return loadSingleRoleServices(entry, serviceFiles) +} + +func loadSingleRoleServices(entry *CatalogEntry, serviceFiles []string) (*CatalogEntry, error) { + for _, path := range serviceFiles { + data, err := os.ReadFile(path) //nolint:gosec // catalog path is user-supplied intentionally + if err != nil { + return nil, fmt.Errorf("reading service file %q: %w", path, err) + } + entry.ServiceFiles[filepath.Base(path)] = string(data) + } + return entry, nil +} + +func loadMultiRoleServices(entry *CatalogEntry, serviceFiles []string) (*CatalogEntry, error) { + svcByRole := make(map[string]string) + for _, path := range serviceFiles { + base := filepath.Base(path) + role := strings.TrimSuffix(base, ".service") + data, err := os.ReadFile(path) //nolint:gosec // catalog path is user-supplied intentionally + if err != nil { + return nil, fmt.Errorf("reading service file %q: %w", path, err) + } + svcByRole[role] = string(data) + } + + for _, rd := range entry.Manifest.Roles { + if _, ok := svcByRole[rd.Name]; !ok { + return nil, fmt.Errorf( + "role %q in catalog entry %q: %w", + rd.Name, entry.Name, ErrCatalogMissingRoleService, + ) + } + } + + entry.ServiceFiles = svcByRole + return entry, nil +} diff --git a/internal/workloads/catalog_test.go b/internal/workloads/catalog_test.go new file mode 100644 index 0000000..11a772f --- /dev/null +++ b/internal/workloads/catalog_test.go @@ -0,0 +1,354 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/virtwork/internal/workloads" +) + +var _ = Describe("CatalogEntry", func() { + var catalogDir string + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-catalog-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + Describe("LoadCatalogEntry", func() { + Context("single-role entry with no manifest", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "my-stress") + writeFile(entryDir, "workload.service", `[Unit] +Description=My stress test + +[Service] +ExecStart=/usr/bin/stress-ng --cpu 0 +Restart=always + +[Install] +WantedBy=multi-user.target +`) + }) + + It("should load successfully", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-stress") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Name).To(Equal("my-stress")) + Expect(entry.ServiceFiles).To(HaveLen(1)) + Expect(entry.ServiceFiles).To(HaveKey("workload.service")) + }) + + It("should have empty manifest fields", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-stress") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Packages).To(BeEmpty()) + Expect(entry.Manifest.Params).To(BeEmpty()) + Expect(entry.Manifest.Roles).To(BeEmpty()) + }) + + It("should not be multi-role", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-stress") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.IsMultiRole()).To(BeFalse()) + }) + + It("should return empty param schema", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-stress") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Schema()).To(BeEmpty()) + }) + }) + + Context("single-role entry with manifest", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "my-cpu-lite") + writeFile(entryDir, "workload.yaml", `description: "Lightweight CPU stress" +packages: + - stress-ng +params: + - key: cpu-load + type: int + default: "50" + desc: "CPU load percentage" + - key: method + type: string + default: "all" + desc: "stress-ng method" +`) + writeFile(entryDir, "workload.service", `[Unit] +Description=CPU lite + +[Service] +ExecStart=/usr/bin/stress-ng --cpu 0 --cpu-load {{cpu-load}} --cpu-method {{method}} +Restart=always + +[Install] +WantedBy=multi-user.target +`) + }) + + It("should load manifest fields", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-cpu-lite") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Description).To(Equal("Lightweight CPU stress")) + Expect(entry.Manifest.Packages).To(ConsistOf("stress-ng")) + }) + + It("should parse param schema from manifest", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-cpu-lite") + Expect(err).NotTo(HaveOccurred()) + schema := entry.Schema() + Expect(schema).To(HaveLen(2)) + Expect(schema[0].Key).To(Equal("cpu-load")) + Expect(schema[0].Type).To(Equal(workloads.ParamInt)) + Expect(schema[0].Default).To(Equal("50")) + Expect(schema[1].Key).To(Equal("method")) + Expect(schema[1].Type).To(Equal(workloads.ParamString)) + }) + + It("should not be multi-role", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-cpu-lite") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.IsMultiRole()).To(BeFalse()) + }) + }) + + Context("multi-role entry", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "my-benchmark") + writeFile(entryDir, "workload.yaml", `description: "Server/client benchmark" +packages: + - iperf3 +params: + - key: duration + type: int + default: "60" + desc: "Test duration" +roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +`) + writeFile(entryDir, "server.service", `[Unit] +Description=Benchmark server + +[Service] +ExecStart=/usr/bin/iperf3 -s +Restart=always + +[Install] +WantedBy=multi-user.target +`) + writeFile(entryDir, "client.service", `[Unit] +Description=Benchmark client + +[Service] +ExecStart=/usr/bin/iperf3 -c server -t {{duration}} +Restart=always + +[Install] +WantedBy=multi-user.target +`) + }) + + It("should load successfully", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-benchmark") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Name).To(Equal("my-benchmark")) + Expect(entry.IsMultiRole()).To(BeTrue()) + }) + + It("should have role definitions from manifest", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-benchmark") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Roles).To(HaveLen(2)) + Expect(entry.Manifest.Roles[0].Name).To(Equal("server")) + Expect(entry.Manifest.Roles[0].VMCount).To(Equal(1)) + Expect(entry.Manifest.Roles[1].Name).To(Equal("client")) + Expect(entry.Manifest.Roles[1].VMCount).To(Equal(2)) + }) + + It("should map service files to roles", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-benchmark") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.ServiceFiles).To(HaveKey("server")) + Expect(entry.ServiceFiles).To(HaveKey("client")) + Expect(entry.ServiceFiles["server"]).To(ContainSubstring("iperf3 -s")) + Expect(entry.ServiceFiles["client"]).To(ContainSubstring("iperf3 -c")) + }) + + It("should return param schema", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-benchmark") + Expect(err).NotTo(HaveOccurred()) + schema := entry.Schema() + Expect(schema).To(HaveLen(1)) + Expect(schema[0].Key).To(Equal("duration")) + }) + }) + + Context("multi-role with flexible role names", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "multi-tier") + writeFile(entryDir, "workload.yaml", `description: "Three-tier workload" +roles: + - name: frontend + vm-count: 2 + - name: backend + vm-count: 1 +`) + writeFile(entryDir, "frontend.service", "[Service]\nExecStart=/bin/frontend\n") + writeFile(entryDir, "backend.service", "[Service]\nExecStart=/bin/backend\n") + }) + + It("should accept arbitrary role names", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "multi-tier") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Roles[0].Name).To(Equal("frontend")) + Expect(entry.Manifest.Roles[1].Name).To(Equal("backend")) + Expect(entry.ServiceFiles).To(HaveKey("frontend")) + Expect(entry.ServiceFiles).To(HaveKey("backend")) + }) + }) + + Context("multiple service files in single-role entry", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "multi-svc") + writeFile(entryDir, "app.service", "[Service]\nExecStart=/bin/app\n") + writeFile(entryDir, "monitor.service", "[Service]\nExecStart=/bin/monitor\n") + }) + + It("should discover all service files", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "multi-svc") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.ServiceFiles).To(HaveLen(2)) + Expect(entry.ServiceFiles).To(HaveKey("app.service")) + Expect(entry.ServiceFiles).To(HaveKey("monitor.service")) + }) + }) + + Context("param type parsing", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "typed-params") + writeFile(entryDir, "workload.yaml", `params: + - key: flag + type: bool + default: "true" + desc: "A boolean flag" + - key: items + type: list + default: "a;b;c" + desc: "A list param" + - key: opts + type: dict + default: "k1=v1;k2=v2" + desc: "A dict param" +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + }) + + It("should parse all param types", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "typed-params") + Expect(err).NotTo(HaveOccurred()) + schema := entry.Schema() + Expect(schema).To(HaveLen(3)) + Expect(schema[0].Type).To(Equal(workloads.ParamBool)) + Expect(schema[1].Type).To(Equal(workloads.ParamList)) + Expect(schema[2].Type).To(Equal(workloads.ParamDict)) + }) + }) + + Context("error cases", func() { + It("should return ErrCatalogEntryNotFound for missing directory", func() { + _, err := workloads.LoadCatalogEntry(catalogDir, "nonexistent") + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring(workloads.ErrCatalogEntryNotFound.Error()))) + }) + + It("should return ErrCatalogNoServices when no .service files exist", func() { + entryDir := filepath.Join(catalogDir, "empty-entry") + writeFile(entryDir, "workload.yaml", "description: empty\n") + + _, err := workloads.LoadCatalogEntry(catalogDir, "empty-entry") + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring(workloads.ErrCatalogNoServices.Error()))) + }) + + It("should return ErrCatalogManifestRequired for multi-role without manifest", func() { + entryDir := filepath.Join(catalogDir, "no-manifest-multi") + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/c\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "no-manifest-multi") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.IsMultiRole()).To(BeFalse()) + Expect(entry.ServiceFiles).To(HaveLen(2)) + }) + + It("should return ErrCatalogMissingRoleService when role has no matching service", func() { + entryDir := filepath.Join(catalogDir, "missing-role-svc") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 1 +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s\n") + + _, err := workloads.LoadCatalogEntry(catalogDir, "missing-role-svc") + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring(workloads.ErrCatalogMissingRoleService.Error()))) + }) + }) + }) + + Describe("Factory", func() { + It("should return a factory that creates GenericWorkload for single-role", func() { + entryDir := filepath.Join(catalogDir, "single") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "single") + Expect(err).NotTo(HaveOccurred()) + + factory := entry.Factory() + Expect(factory).NotTo(BeNil()) + }) + + It("should return a factory that creates GenericMultiWorkload for multi-role", func() { + entryDir := filepath.Join(catalogDir, "multi") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 1 +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/c\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "multi") + Expect(err).NotTo(HaveOccurred()) + + factory := entry.Factory() + Expect(factory).NotTo(BeNil()) + }) + }) +}) diff --git a/internal/workloads/generic.go b/internal/workloads/generic.go new file mode 100644 index 0000000..1487a09 --- /dev/null +++ b/internal/workloads/generic.go @@ -0,0 +1,80 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + "sort" + "strings" + + "github.com/opdev/virtwork/internal/config" +) + +// GenericWorkload implements Workload for single-role catalog entries. +type GenericWorkload struct { + BaseWorkload + entryName string + serviceFiles map[string]string + packages []string +} + +// NewGenericWorkload creates a GenericWorkload from a loaded catalog entry. +func NewGenericWorkload( + cfg config.WorkloadConfig, + entry *CatalogEntry, + sshUser, sshPassword string, + sshKeys []string, +) *GenericWorkload { + return &GenericWorkload{ + BaseWorkload: BaseWorkload{ + Config: cfg, + ParamSchema: entry.Schema(), + SSHUser: sshUser, + SSHPassword: sshPassword, + SSHAuthorizedKeys: sshKeys, + }, + entryName: entry.Name, + serviceFiles: entry.ServiceFiles, + packages: entry.Manifest.Packages, + } +} + +// Name returns the catalog entry name. +func (w *GenericWorkload) Name() string { + return w.entryName +} + +// CloudInitUserdata returns cloud-init YAML with the entry's service files installed. +func (w *GenericWorkload) CloudInitUserdata() (string, error) { + names := make([]string, 0, len(w.serviceFiles)) + for name := range w.serviceFiles { + names = append(names, name) + } + sort.Strings(names) + + writeFiles := make([]WriteFile, 0, len(names)) + runcmd := [][]string{{"systemctl", "daemon-reload"}} + + for _, name := range names { + content := w.substituteParams(w.serviceFiles[name]) + writeFiles = append(writeFiles, WriteFile{ + Path: "/etc/systemd/system/" + name, + Content: content, + Permissions: "0644", + }) + runcmd = append(runcmd, []string{"systemctl", "enable", "--now", name}) + } + + return w.BuildCloudConfig(CloudConfigOpts{ + Packages: w.packages, + WriteFiles: writeFiles, + RunCmd: runcmd, + }) +} + +func (w *GenericWorkload) substituteParams(content string) string { + for _, p := range w.ParamSchema { + content = strings.ReplaceAll(content, "{{"+p.Key+"}}", w.GetParam(p.Key)) + } + return content +} diff --git a/internal/workloads/generic_multi.go b/internal/workloads/generic_multi.go new file mode 100644 index 0000000..376da7b --- /dev/null +++ b/internal/workloads/generic_multi.go @@ -0,0 +1,115 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads + +import ( + "errors" + "fmt" + "strings" + + "github.com/opdev/virtwork/internal/config" +) + +var ErrUnknownCatalogRole = errors.New("unknown catalog role") + +// GenericMultiWorkload implements MultiVMWorkload for multi-role catalog entries. +type GenericMultiWorkload struct { + BaseWorkload + entryName string + namespace string + roles []RoleDefinition + serviceFiles map[string]string + packages []string +} + +// NewGenericMultiWorkload creates a GenericMultiWorkload from a loaded catalog entry. +func NewGenericMultiWorkload( + cfg config.WorkloadConfig, + entry *CatalogEntry, + namespace, sshUser, sshPassword string, + sshKeys []string, +) *GenericMultiWorkload { + return &GenericMultiWorkload{ + BaseWorkload: BaseWorkload{ + Config: cfg, + ParamSchema: entry.Schema(), + SSHUser: sshUser, + SSHPassword: sshPassword, + SSHAuthorizedKeys: sshKeys, + }, + entryName: entry.Name, + namespace: namespace, + roles: entry.Manifest.Roles, + serviceFiles: entry.ServiceFiles, + packages: entry.Manifest.Packages, + } +} + +// Name returns the catalog entry name. +func (w *GenericMultiWorkload) Name() string { + return w.entryName +} + +// CloudInitUserdata returns the first role's userdata as the default. +func (w *GenericMultiWorkload) CloudInitUserdata() (string, error) { + if len(w.roles) == 0 { + return w.BuildCloudConfig(CloudConfigOpts{}) + } + return w.UserdataForRole(w.roles[0].Name, w.namespace) +} + +// RoleDistribution returns per-role VM counts from the manifest. +func (w *GenericMultiWorkload) RoleDistribution() []RoleSpec { + specs := make([]RoleSpec, len(w.roles)) + for i, rd := range w.roles { + count := rd.VMCount + if count < 1 { + count = max(1, w.Config.VMCount) + } + specs[i] = RoleSpec{Role: rd.Name, VMCount: count} + } + return specs +} + +// VMCount returns the total VM count across all roles. +func (w *GenericMultiWorkload) VMCount() int { + total := 0 + for _, rs := range w.RoleDistribution() { + total += rs.VMCount + } + return total +} + +// UserdataForRole returns cloud-init YAML for a specific role. +func (w *GenericMultiWorkload) UserdataForRole(role string, _ string) (string, error) { + svcContent, ok := w.serviceFiles[role] + if !ok { + return "", fmt.Errorf("role %q: %w", role, ErrUnknownCatalogRole) + } + + content := w.substituteParams(svcContent) + svcFilename := role + ".service" + + return w.BuildCloudConfig(CloudConfigOpts{ + Packages: w.packages, + WriteFiles: []WriteFile{ + { + Path: "/etc/systemd/system/" + svcFilename, + Content: content, + Permissions: "0644", + }, + }, + RunCmd: [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", svcFilename}, + }, + }) +} + +func (w *GenericMultiWorkload) substituteParams(content string) string { + for _, p := range w.ParamSchema { + content = strings.ReplaceAll(content, "{{"+p.Key+"}}", w.GetParam(p.Key)) + } + return content +} diff --git a/internal/workloads/generic_multi_test.go b/internal/workloads/generic_multi_test.go new file mode 100644 index 0000000..600f58e --- /dev/null +++ b/internal/workloads/generic_multi_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/workloads" +) + +var _ = Describe("GenericMultiWorkload", func() { + var ( + catalogDir string + w *workloads.GenericMultiWorkload + ) + + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-generic-multi-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + Context("two-role server/client entry", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "my-bench") + writeFile(entryDir, "workload.yaml", `description: "Server/client benchmark" +packages: + - iperf3 +params: + - key: duration + type: int + default: "60" + desc: "Test duration in seconds" +roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +`) + writeFile(entryDir, "server.service", `[Unit] +Description=Benchmark server + +[Service] +ExecStart=/usr/bin/iperf3 -s -t {{duration}} +Restart=always + +[Install] +WantedBy=multi-user.target +`) + writeFile(entryDir, "client.service", `[Unit] +Description=Benchmark client + +[Service] +ExecStart=/usr/bin/iperf3 -c server -t {{duration}} +Restart=always + +[Install] +WantedBy=multi-user.target +`) + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-bench") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 4, Memory: "4Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should return the entry name for Name", func() { + Expect(w.Name()).To(Equal("my-bench")) + }) + + It("should return role distribution matching manifest", func() { + dist := w.RoleDistribution() + Expect(dist).To(HaveLen(2)) + Expect(dist[0].Role).To(Equal("server")) + Expect(dist[0].VMCount).To(Equal(1)) + Expect(dist[1].Role).To(Equal("client")) + Expect(dist[1].VMCount).To(Equal(2)) + }) + + It("should return total VMCount across all roles", func() { + Expect(w.VMCount()).To(Equal(3)) + }) + + It("should return first role's userdata for CloudInitUserdata", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HavePrefix("#cloud-config\n")) + Expect(result).To(ContainSubstring("iperf3 -s")) + }) + + It("should produce server userdata with UserdataForRole", func() { + result, err := w.UserdataForRole("server", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + + files := parsed["write_files"].([]interface{}) + Expect(files).To(HaveLen(1)) + fm := files[0].(map[string]interface{}) + Expect(fm["path"]).To(Equal("/etc/systemd/system/server.service")) + Expect(fm["content"]).To(ContainSubstring("iperf3 -s")) + }) + + It("should produce client userdata with UserdataForRole", func() { + result, err := w.UserdataForRole("client", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + + files := parsed["write_files"].([]interface{}) + Expect(files).To(HaveLen(1)) + fm := files[0].(map[string]interface{}) + Expect(fm["path"]).To(Equal("/etc/systemd/system/client.service")) + Expect(fm["content"]).To(ContainSubstring("iperf3 -c")) + }) + + It("should substitute default param values in role userdata", func() { + result, err := w.UserdataForRole("server", "test-ns") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("-t 60")) + Expect(result).NotTo(ContainSubstring("{{duration}}")) + }) + + It("should return ErrUnknownCatalogRole for unknown role", func() { + _, err := w.UserdataForRole("unknown", "test-ns") + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring(workloads.ErrUnknownCatalogRole.Error()))) + }) + + It("should include packages in role userdata", func() { + result, err := w.UserdataForRole("server", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + pkgs, ok := parsed["packages"].([]interface{}) + Expect(ok).To(BeTrue()) + Expect(pkgs).To(ContainElement("iperf3")) + }) + + It("should include daemon-reload and enable in runcmd", func() { + result, err := w.UserdataForRole("client", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "daemon-reload"), + )) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "enable", "--now", "client.service"), + )) + }) + + It("should not require a service", func() { + Expect(w.RequiresService()).To(BeFalse()) + }) + + It("should reflect config in VMResources", func() { + res := w.VMResources() + Expect(res.CPUCores).To(Equal(4)) + Expect(res.Memory).To(Equal("4Gi")) + }) + }) + + Context("user-supplied param values", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "param-multi") + writeFile(entryDir, "workload.yaml", `params: + - key: duration + type: int + default: "60" + desc: "Duration" +roles: + - name: server + vm-count: 1 + - name: client + vm-count: 1 +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s -t {{duration}}\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/c -t {{duration}}\n") + }) + + It("should substitute user params in role userdata", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "param-multi") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{ + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{"duration": "120"}, + }, + entry, "ns", "", "", nil, + ) + result, err := w.UserdataForRole("server", "ns") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("-t 120")) + }) + }) + + Context("role with vm-count 0 uses Config.VMCount", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "zero-count") + writeFile(entryDir, "workload.yaml", `roles: + - name: worker + vm-count: 0 + - name: controller + vm-count: 1 +`) + writeFile(entryDir, "worker.service", "[Service]\nExecStart=/bin/w\n") + writeFile(entryDir, "controller.service", "[Service]\nExecStart=/bin/c\n") + }) + + It("should use max(1, Config.VMCount) when manifest vm-count is 0", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "zero-count") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi", VMCount: 3}, + entry, "ns", "", "", nil, + ) + dist := w.RoleDistribution() + Expect(dist).To(HaveLen(2)) + + var workerCount, controllerCount int + for _, rs := range dist { + if rs.Role == "worker" { + workerCount = rs.VMCount + } + if rs.Role == "controller" { + controllerCount = rs.VMCount + } + } + Expect(workerCount).To(Equal(3)) + Expect(controllerCount).To(Equal(1)) + }) + + It("should default to 1 when both manifest and config are 0", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "zero-count") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi", VMCount: 0}, + entry, "ns", "", "", nil, + ) + dist := w.RoleDistribution() + var workerCount int + for _, rs := range dist { + if rs.Role == "worker" { + workerCount = rs.VMCount + } + } + Expect(workerCount).To(Equal(1)) + }) + }) + + Context("interface compliance", func() { + It("should implement MultiVMWorkload interface", func() { + entryDir := filepath.Join(catalogDir, "iface-check") + writeFile(entryDir, "workload.yaml", `roles: + - name: a + vm-count: 1 + - name: b + vm-count: 1 +`) + writeFile(entryDir, "a.service", "[Service]\nExecStart=/bin/a\n") + writeFile(entryDir, "b.service", "[Service]\nExecStart=/bin/b\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "iface-check") + Expect(err).NotTo(HaveOccurred()) + multi := workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "ns", "", "", nil, + ) + var _ workloads.MultiVMWorkload = multi + }) + }) +}) diff --git a/internal/workloads/generic_test.go b/internal/workloads/generic_test.go new file mode 100644 index 0000000..1d209f6 --- /dev/null +++ b/internal/workloads/generic_test.go @@ -0,0 +1,273 @@ +// Copyright 2026 Red Hat +// SPDX-License-Identifier: Apache-2.0 + +package workloads_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/workloads" +) + +var _ = Describe("GenericWorkload", func() { + var ( + catalogDir string + w *workloads.GenericWorkload + ) + + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + var err error + catalogDir, err = os.MkdirTemp("", "virtwork-generic-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(catalogDir)).To(Succeed()) + }) + + Context("basic single-service entry with no manifest", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "my-stress") + writeFile(entryDir, "workload.service", `[Unit] +Description=My stress test + +[Service] +ExecStart=/usr/bin/stress-ng --cpu 0 +Restart=always + +[Install] +WantedBy=multi-user.target +`) + entry, err := workloads.LoadCatalogEntry(catalogDir, "my-stress") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{ + Enabled: new(true), + VMCount: 1, + CPUCores: 2, + Memory: "2Gi", + }, + entry, "virtwork", "", nil, + ) + }) + + It("should return the entry name for Name", func() { + Expect(w.Name()).To(Equal("my-stress")) + }) + + It("should produce valid cloud-init userdata", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HavePrefix("#cloud-config\n")) + }) + + It("should include service file as write_files", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + found := false + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/etc/systemd/system/workload.service" { + found = true + Expect(fm["content"]).To(ContainSubstring("stress-ng --cpu 0")) + } + } + Expect(found).To(BeTrue(), "expected workload.service in write_files") + }) + + It("should enable the service via runcmd", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "daemon-reload"), + )) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "enable", "--now", "workload.service"), + )) + }) + + It("should reflect config in VMResources", func() { + res := w.VMResources() + Expect(res.CPUCores).To(Equal(2)) + Expect(res.Memory).To(Equal("2Gi")) + }) + + It("should not require a service", func() { + Expect(w.RequiresService()).To(BeFalse()) + }) + + It("should return nil for ExtraVolumes", func() { + Expect(w.ExtraVolumes()).To(BeNil()) + }) + + It("should return nil for ExtraDisks", func() { + Expect(w.ExtraDisks()).To(BeNil()) + }) + + It("should return nil for DataVolumeTemplates", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(BeNil()) + }) + + It("should return configured VMCount", func() { + Expect(w.VMCount()).To(Equal(1)) + }) + }) + + Context("entry with manifest packages", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "with-pkgs") + writeFile(entryDir, "workload.yaml", `description: "With packages" +packages: + - stress-ng + - htop +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/usr/bin/stress-ng\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "with-pkgs") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "", "", nil, + ) + }) + + It("should include packages in cloud-init", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + pkgs, ok := parsed["packages"].([]interface{}) + Expect(ok).To(BeTrue()) + Expect(pkgs).To(ContainElement("stress-ng")) + Expect(pkgs).To(ContainElement("htop")) + }) + }) + + Context("param substitution", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "param-sub") + writeFile(entryDir, "workload.yaml", `params: + - key: cpu-load + type: int + default: "50" + desc: "CPU load" + - key: method + type: string + default: "all" + desc: "Method" +`) + writeFile( + entryDir, + "workload.service", + "[Service]\nExecStart=/usr/bin/stress-ng --cpu-load {{cpu-load}} --method {{method}}\n", + ) + }) + + It("should substitute default param values", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "param-sub") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "", "", nil, + ) + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("--cpu-load 50")) + Expect(result).To(ContainSubstring("--method all")) + }) + + It("should substitute user-supplied param values", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "param-sub") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{ + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{ + "cpu-load": "75", + "method": "matrixprod", + }, + }, + entry, "", "", nil, + ) + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("--cpu-load 75")) + Expect(result).To(ContainSubstring("--method matrixprod")) + }) + + It("should use defaults for missing individual params", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "param-sub") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{ + CPUCores: 2, + Memory: "2Gi", + Params: map[string]string{"cpu-load": "90"}, + }, + entry, "", "", nil, + ) + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("--cpu-load 90")) + Expect(result).To(ContainSubstring("--method all")) + }) + }) + + Context("multiple service files", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "multi-svc") + writeFile(entryDir, "app.service", "[Service]\nExecStart=/bin/app\n") + writeFile(entryDir, "monitor.service", "[Service]\nExecStart=/bin/monitor\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "multi-svc") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "", "", nil, + ) + }) + + It("should include all service files in write_files", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + paths := make([]string, 0, len(files)) + for _, f := range files { + fm := f.(map[string]interface{}) + paths = append(paths, fm["path"].(string)) + } + Expect(paths).To(ContainElement("/etc/systemd/system/app.service")) + Expect(paths).To(ContainElement("/etc/systemd/system/monitor.service")) + }) + + It("should enable all services via runcmd", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "enable", "--now", "app.service"), + )) + Expect(runcmd).To(ContainElement( + ConsistOf("systemctl", "enable", "--now", "monitor.service"), + )) + }) + }) +}) diff --git a/internal/workloads/registry.go b/internal/workloads/registry.go index 9dfea7f..d959436 100644 --- a/internal/workloads/registry.go +++ b/internal/workloads/registry.go @@ -26,6 +26,7 @@ type RegistryOpts struct { SSHUser string SSHPassword string SSHAuthorizedKeys []string + CatalogDir string } // Option is a functional option for workload construction. @@ -45,6 +46,11 @@ func WithSSHCredentials(user, password string, keys []string) Option { } } +// WithCatalogDir sets the catalog directory path for loading catalog workload entries. +func WithCatalogDir(dir string) Option { + return func(o *RegistryOpts) { o.CatalogDir = dir } +} + // WithDataDiskSize sets the data disk size for workloads that use persistent storage. func WithDataDiskSize(size string) Option { return func(o *RegistryOpts) { o.DataDiskSize = size } diff --git a/internal/workloads/registry_test.go b/internal/workloads/registry_test.go index a3e671c..3d4ea16 100644 --- a/internal/workloads/registry_test.go +++ b/internal/workloads/registry_test.go @@ -215,6 +215,12 @@ var _ = Describe("Registry", func() { Expect(err).NotTo(HaveOccurred()) Expect(dvts).NotTo(BeEmpty()) }) + + It("should set CatalogDir via WithCatalogDir option", func() { + opts := &workloads.RegistryOpts{} + workloads.WithCatalogDir("/tmp/my-catalog")(opts) + Expect(opts.CatalogDir).To(Equal("/tmp/my-catalog")) + }) }) var _ = Describe("AllWorkloadNames", func() {