Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions cmd/virtwork/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ var (
date = ""

clusterConnect = cluster.Connect

ErrNoWorkloads = errors.New("no workloads specified: use --workloads or --from-catalog")
)

func main() {
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions cmd/virtwork/rune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
})
})
32 changes: 29 additions & 3 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading