From bf7a7396065816e605aee0eea174b71cefeb93be Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sat, 13 Jun 2026 17:47:48 -0500 Subject: [PATCH 1/3] feat: add storage and service types to catalog manifest schema Extend CatalogManifest with Storage and Service fields. Add StorageDefinition, ServiceDefinition, and ServicePort types for YAML unmarshaling. Add validation in LoadCatalogEntry with error sentinels for name, size, serial, mount, port, and protocol constraints. Add buildCatalogServiceSpec and convertServicePorts helpers shared by GenericWorkload and GenericMultiWorkload. Update NewGenericWorkload to accept namespace parameter for service generation. Implement ExtraVolumes, ExtraDisks, DataVolumeTemplates, RequiresService, and ServiceSpec overrides on both GenericWorkload and GenericMultiWorkload. Inject diskSetupScript into cloud-init userdata when storage is declared. Refs: #196 Signed-off-by: Melvin Hillsman --- internal/workloads/catalog.go | 148 ++++++++++++++- internal/workloads/catalog_test.go | 274 ++++++++++++++++++++++++++++ internal/workloads/generic.go | 97 +++++++++- internal/workloads/generic_multi.go | 115 ++++++++++-- internal/workloads/generic_test.go | 12 +- 5 files changed, 620 insertions(+), 26 deletions(-) diff --git a/internal/workloads/catalog.go b/internal/workloads/catalog.go index c310c20..9df57d2 100644 --- a/internal/workloads/catalog.go +++ b/internal/workloads/catalog.go @@ -11,9 +11,14 @@ import ( "sort" "strings" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" "gopkg.in/yaml.v3" "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/constants" ) var ( @@ -21,6 +26,17 @@ var ( 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") + + ErrStorageNameEmpty = errors.New("storage name must not be empty") + ErrStorageNameReserved = errors.New("storage name conflicts with reserved disk name") + ErrStorageNameDuplicate = errors.New("duplicate storage name") + ErrStorageSizeInvalid = errors.New("storage size must be a valid quantity") + ErrStorageSerialEmpty = errors.New("storage serial must not be empty") + ErrStorageSerialTooLong = errors.New("storage serial must be at most 20 characters") + ErrStorageMountNotAbsolute = errors.New("storage mount must be an absolute path") + ErrServicePortsEmpty = errors.New("service must declare at least one port") + ErrServicePortRange = errors.New("service port must be between 1 and 65535") + ErrServiceProtocol = errors.New("service protocol must be TCP or UDP") ) // RoleDefinition declares a role and its default VM count in a catalog manifest. @@ -29,6 +45,27 @@ type RoleDefinition struct { VMCount int `yaml:"vm-count"` } +// StorageDefinition declares a persistent storage volume in a catalog manifest. +type StorageDefinition struct { + Name string `yaml:"name"` + Size string `yaml:"size"` + Serial string `yaml:"serial"` + Mount string `yaml:"mount"` +} + +// ServicePort declares a port for a K8s Service in a catalog manifest. +type ServicePort struct { + Name string `yaml:"name"` + Port int32 `yaml:"port"` + Protocol string `yaml:"protocol"` +} + +// ServiceDefinition declares a K8s Service in a catalog manifest. +type ServiceDefinition struct { + Ports []ServicePort `yaml:"ports"` + SelectorRole string `yaml:"selector-role"` +} + // catalogParamDef mirrors ParamDef for YAML unmarshaling with string type names. type catalogParamDef struct { Key string `yaml:"key"` @@ -39,10 +76,12 @@ type catalogParamDef struct { // 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"` + Description string `yaml:"description"` + Packages []string `yaml:"packages"` + Params []catalogParamDef `yaml:"params"` + Roles []RoleDefinition `yaml:"roles"` + Storage []StorageDefinition `yaml:"storage"` + Service *ServiceDefinition `yaml:"service"` } // CatalogEntry represents a loaded catalog workload entry. @@ -101,11 +140,107 @@ func (e *CatalogEntry) Factory() WorkloadFactory { } } return func(cfg config.WorkloadConfig, opts *RegistryOpts) Workload { - return NewGenericWorkload(cfg, entry, + return NewGenericWorkload(cfg, entry, opts.Namespace, opts.SSHUser, opts.SSHPassword, opts.SSHAuthorizedKeys) } } +var reservedDiskNames = map[string]bool{ + "containerdisk": true, + "cloudinitdisk": true, + "datadisk": true, +} + +func validateManifest(m *CatalogManifest) error { + seen := make(map[string]bool, len(m.Storage)) + for _, s := range m.Storage { + if s.Name == "" { + return ErrStorageNameEmpty + } + if reservedDiskNames[s.Name] { + return fmt.Errorf("%q: %w", s.Name, ErrStorageNameReserved) + } + if seen[s.Name] { + return fmt.Errorf("%q: %w", s.Name, ErrStorageNameDuplicate) + } + if _, err := resource.ParseQuantity(s.Size); err != nil { + return fmt.Errorf("%q: %w", s.Size, ErrStorageSizeInvalid) + } + if s.Serial == "" { + return ErrStorageSerialEmpty + } + if len(s.Serial) > 20 { + return fmt.Errorf("%q: %w", s.Serial, ErrStorageSerialTooLong) + } + if !filepath.IsAbs(s.Mount) { + return fmt.Errorf("%q: %w", s.Mount, ErrStorageMountNotAbsolute) + } + seen[s.Name] = true + } + + if m.Service != nil { + if len(m.Service.Ports) == 0 { + return ErrServicePortsEmpty + } + for _, p := range m.Service.Ports { + if p.Port < 1 || p.Port > 65535 { + return fmt.Errorf("%d: %w", p.Port, ErrServicePortRange) + } + proto := strings.ToUpper(p.Protocol) + if proto != "" && proto != "TCP" && proto != "UDP" { + return fmt.Errorf("%q: %w", p.Protocol, ErrServiceProtocol) + } + } + } + return nil +} + +func convertServicePorts(ports []ServicePort) []corev1.ServicePort { + result := make([]corev1.ServicePort, len(ports)) + for i, p := range ports { + proto := corev1.ProtocolTCP + if strings.ToUpper(p.Protocol) == "UDP" { + proto = corev1.ProtocolUDP + } + result[i] = corev1.ServicePort{ + Name: p.Name, + Port: p.Port, + TargetPort: intstr.FromInt32(p.Port), + Protocol: proto, + } + } + return result +} + +func buildCatalogServiceSpec(name, namespace string, def *ServiceDefinition) *corev1.Service { + if def == nil { + return nil + } + selector := map[string]string{ + constants.LabelAppName: "virtwork-" + name, + } + if def.SelectorRole != "" { + selector = map[string]string{ + "virtwork/role": def.SelectorRole, + } + } + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "virtwork-" + name, + Namespace: namespace, + Labels: map[string]string{ + constants.LabelAppName: constants.ManagedByValue, + constants.LabelManagedBy: constants.ManagedByValue, + constants.LabelComponent: name, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + Ports: convertServicePorts(def.Ports), + }, + } +} + // 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"). @@ -128,6 +263,9 @@ func LoadCatalogEntry(catalogDir, entryName string) (*CatalogEntry, error) { if err := yaml.Unmarshal(data, &entry.Manifest); err != nil { return nil, fmt.Errorf("parsing workload.yaml for %q: %w", entryName, err) } + if err := validateManifest(&entry.Manifest); err != nil { + return nil, fmt.Errorf("validating manifest for %q: %w", entryName, err) + } } serviceFiles, err := filepath.Glob(filepath.Join(entryDir, "*.service")) diff --git a/internal/workloads/catalog_test.go b/internal/workloads/catalog_test.go index 11a772f..9baae93 100644 --- a/internal/workloads/catalog_test.go +++ b/internal/workloads/catalog_test.go @@ -277,6 +277,280 @@ roles: }) }) + Context("single-role entry with storage", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "with-storage") + writeFile(entryDir, "workload.yaml", `description: "Storage workload" +storage: + - name: data + size: 10Gi + serial: vw-data + mount: /mnt/data + - name: logs + size: 5Gi + serial: vw-logs + mount: /var/log/app +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + }) + + It("should parse storage definitions", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "with-storage") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Storage).To(HaveLen(2)) + + Expect(entry.Manifest.Storage[0].Name).To(Equal("data")) + Expect(entry.Manifest.Storage[0].Size).To(Equal("10Gi")) + Expect(entry.Manifest.Storage[0].Serial).To(Equal("vw-data")) + Expect(entry.Manifest.Storage[0].Mount).To(Equal("/mnt/data")) + + Expect(entry.Manifest.Storage[1].Name).To(Equal("logs")) + Expect(entry.Manifest.Storage[1].Size).To(Equal("5Gi")) + Expect(entry.Manifest.Storage[1].Serial).To(Equal("vw-logs")) + Expect(entry.Manifest.Storage[1].Mount).To(Equal("/var/log/app")) + }) + }) + + Context("single-role entry with service", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "with-service") + writeFile(entryDir, "workload.yaml", `description: "Service workload" +service: + ports: + - name: http + port: 8080 + protocol: TCP + - name: metrics + port: 9090 + protocol: TCP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + }) + + It("should parse service definition", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "with-service") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Service).NotTo(BeNil()) + Expect(entry.Manifest.Service.Ports).To(HaveLen(2)) + Expect(entry.Manifest.Service.Ports[0].Name).To(Equal("http")) + Expect(entry.Manifest.Service.Ports[0].Port).To(Equal(int32(8080))) + Expect(entry.Manifest.Service.Ports[0].Protocol).To(Equal("TCP")) + Expect(entry.Manifest.Service.Ports[1].Name).To(Equal("metrics")) + Expect(entry.Manifest.Service.Ports[1].Port).To(Equal(int32(9090))) + }) + }) + + Context("multi-role entry with service and selector-role", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "svc-role") + writeFile(entryDir, "workload.yaml", `description: "Service with role selector" +roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +service: + selector-role: server + ports: + - name: iperf + port: 5201 + protocol: TCP +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/s\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/c\n") + }) + + It("should parse service with selector-role", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "svc-role") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Service).NotTo(BeNil()) + Expect(entry.Manifest.Service.SelectorRole).To(Equal("server")) + Expect(entry.Manifest.Service.Ports).To(HaveLen(1)) + }) + }) + + Context("entry with storage and service combined", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "full-featured") + writeFile(entryDir, "workload.yaml", `description: "Full featured" +packages: + - postgresql +storage: + - name: pgdata + size: 20Gi + serial: vw-pgdata + mount: /var/lib/pgsql +service: + ports: + - name: postgres + port: 5432 + protocol: TCP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/pg\n") + }) + + It("should parse both storage and service", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "full-featured") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Storage).To(HaveLen(1)) + Expect(entry.Manifest.Service).NotTo(BeNil()) + Expect(entry.Manifest.Packages).To(ConsistOf("postgresql")) + }) + }) + + Context("backward compatibility without storage or service", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "legacy") + writeFile(entryDir, "workload.yaml", `description: "Legacy workload" +packages: + - stress-ng +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/stress\n") + }) + + It("should have nil storage and service", func() { + entry, err := workloads.LoadCatalogEntry(catalogDir, "legacy") + Expect(err).NotTo(HaveOccurred()) + Expect(entry.Manifest.Storage).To(BeEmpty()) + Expect(entry.Manifest.Service).To(BeNil()) + }) + }) + + Context("storage validation errors", func() { + It("should reject empty storage name", func() { + entryDir := filepath.Join(catalogDir, "bad-name") + writeFile(entryDir, "workload.yaml", `storage: + - name: "" + size: 10Gi + serial: vw-data + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "bad-name") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageNameEmpty.Error()))) + }) + + It("should reject reserved disk name", func() { + entryDir := filepath.Join(catalogDir, "reserved-name") + writeFile(entryDir, "workload.yaml", `storage: + - name: datadisk + size: 10Gi + serial: vw-data + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "reserved-name") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageNameReserved.Error()))) + }) + + It("should reject duplicate storage names", func() { + entryDir := filepath.Join(catalogDir, "dup-name") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: 10Gi + serial: vw-d1 + mount: /mnt/d1 + - name: data + size: 5Gi + serial: vw-d2 + mount: /mnt/d2 +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "dup-name") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageNameDuplicate.Error()))) + }) + + It("should reject invalid storage size", func() { + entryDir := filepath.Join(catalogDir, "bad-size") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: not-a-quantity + serial: vw-data + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "bad-size") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageSizeInvalid.Error()))) + }) + + It("should reject empty serial", func() { + entryDir := filepath.Join(catalogDir, "no-serial") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: 10Gi + serial: "" + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "no-serial") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageSerialEmpty.Error()))) + }) + + It("should reject serial longer than 20 characters", func() { + entryDir := filepath.Join(catalogDir, "long-serial") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: 10Gi + serial: this-serial-is-way-too-long-for-virtio + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "long-serial") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageSerialTooLong.Error()))) + }) + + It("should reject relative mount path", func() { + entryDir := filepath.Join(catalogDir, "rel-mount") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: 10Gi + serial: vw-data + mount: data/dir +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "rel-mount") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrStorageMountNotAbsolute.Error()))) + }) + }) + + Context("service validation errors", func() { + It("should reject empty ports list", func() { + entryDir := filepath.Join(catalogDir, "no-ports") + writeFile(entryDir, "workload.yaml", `service: + ports: [] +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "no-ports") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrServicePortsEmpty.Error()))) + }) + + It("should reject port out of range", func() { + entryDir := filepath.Join(catalogDir, "bad-port") + writeFile(entryDir, "workload.yaml", `service: + ports: + - name: bad + port: 0 + protocol: TCP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "bad-port") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrServicePortRange.Error()))) + }) + + It("should reject invalid protocol", func() { + entryDir := filepath.Join(catalogDir, "bad-proto") + writeFile(entryDir, "workload.yaml", `service: + ports: + - name: app + port: 8080 + protocol: SCTP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + _, err := workloads.LoadCatalogEntry(catalogDir, "bad-proto") + Expect(err).To(MatchError(ContainSubstring(workloads.ErrServiceProtocol.Error()))) + }) + }) + Context("error cases", func() { It("should return ErrCatalogEntryNotFound for missing directory", func() { _, err := workloads.LoadCatalogEntry(catalogDir, "nonexistent") diff --git a/internal/workloads/generic.go b/internal/workloads/generic.go index 1487a09..a6329d0 100644 --- a/internal/workloads/generic.go +++ b/internal/workloads/generic.go @@ -4,25 +4,35 @@ package workloads import ( + "fmt" "sort" "strings" + kubevirtv1 "kubevirt.io/api/core/v1" + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/constants" + "github.com/opdev/virtwork/internal/vm" + + corev1 "k8s.io/api/core/v1" ) // GenericWorkload implements Workload for single-role catalog entries. type GenericWorkload struct { BaseWorkload entryName string + namespace string serviceFiles map[string]string packages []string + storageSpecs []StorageDefinition + serviceDef *ServiceDefinition } // NewGenericWorkload creates a GenericWorkload from a loaded catalog entry. func NewGenericWorkload( cfg config.WorkloadConfig, entry *CatalogEntry, - sshUser, sshPassword string, + namespace, sshUser, sshPassword string, sshKeys []string, ) *GenericWorkload { return &GenericWorkload{ @@ -34,8 +44,11 @@ func NewGenericWorkload( SSHAuthorizedKeys: sshKeys, }, entryName: entry.Name, + namespace: namespace, serviceFiles: entry.ServiceFiles, packages: entry.Manifest.Packages, + storageSpecs: entry.Manifest.Storage, + serviceDef: entry.Manifest.Service, } } @@ -52,8 +65,21 @@ func (w *GenericWorkload) CloudInitUserdata() (string, error) { } sort.Strings(names) - writeFiles := make([]WriteFile, 0, len(names)) - runcmd := [][]string{{"systemctl", "daemon-reload"}} + writeFiles := make([]WriteFile, 0, len(w.storageSpecs)+len(names)) + runcmd := make([][]string, 0, len(w.storageSpecs)+1+len(names)) + + for _, spec := range w.storageSpecs { + writeFiles = append(writeFiles, WriteFile{ + Path: fmt.Sprintf("/usr/local/bin/virtwork-disk-setup-%s.sh", spec.Name), + Content: diskSetupScript(spec.Serial, spec.Mount), + Permissions: "0755", + }) + runcmd = append(runcmd, []string{ + fmt.Sprintf("/usr/local/bin/virtwork-disk-setup-%s.sh", spec.Name), + }) + } + + runcmd = append(runcmd, []string{"systemctl", "daemon-reload"}) for _, name := range names { content := w.substituteParams(w.serviceFiles[name]) @@ -72,6 +98,71 @@ func (w *GenericWorkload) CloudInitUserdata() (string, error) { }) } +// DataVolumeTemplates returns CDI DataVolumeTemplateSpecs for declared storage. +func (w *GenericWorkload) DataVolumeTemplates() ([]kubevirtv1.DataVolumeTemplateSpec, error) { + if len(w.storageSpecs) == 0 { + return nil, nil + } + dvts := make([]kubevirtv1.DataVolumeTemplateSpec, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + dvt, err := vm.BuildDataVolumeTemplate(spec.Name, spec.Size) + if err != nil { + return nil, fmt.Errorf("building data volume for storage %q: %w", spec.Name, err) + } + dvts[i] = dvt + } + return dvts, nil +} + +// ExtraDisks returns disk definitions for all declared storage. +func (w *GenericWorkload) ExtraDisks() []kubevirtv1.Disk { + if len(w.storageSpecs) == 0 { + return nil + } + disks := make([]kubevirtv1.Disk, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + disks[i] = kubevirtv1.Disk{ + Name: spec.Name, + Serial: spec.Serial, + DiskDevice: kubevirtv1.DiskDevice{ + Disk: &kubevirtv1.DiskTarget{ + Bus: constants.DiskBusVirtio, + }, + }, + } + } + return disks +} + +// ExtraVolumes returns volume definitions for all declared storage. +func (w *GenericWorkload) ExtraVolumes() []kubevirtv1.Volume { + if len(w.storageSpecs) == 0 { + return nil + } + volumes := make([]kubevirtv1.Volume, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + volumes[i] = kubevirtv1.Volume{ + Name: spec.Name, + VolumeSource: kubevirtv1.VolumeSource{ + DataVolume: &kubevirtv1.DataVolumeSource{ + Name: spec.Name, + }, + }, + } + } + return volumes +} + +// RequiresService returns true if a service is declared in the manifest. +func (w *GenericWorkload) RequiresService() bool { + return w.serviceDef != nil +} + +// ServiceSpec returns the K8s Service, or nil if not declared. +func (w *GenericWorkload) ServiceSpec() *corev1.Service { + return buildCatalogServiceSpec(w.Name(), w.namespace, w.serviceDef) +} + func (w *GenericWorkload) substituteParams(content string) string { for _, p := range w.ParamSchema { content = strings.ReplaceAll(content, "{{"+p.Key+"}}", w.GetParam(p.Key)) diff --git a/internal/workloads/generic_multi.go b/internal/workloads/generic_multi.go index 376da7b..c6e4e6b 100644 --- a/internal/workloads/generic_multi.go +++ b/internal/workloads/generic_multi.go @@ -8,7 +8,13 @@ import ( "fmt" "strings" + kubevirtv1 "kubevirt.io/api/core/v1" + "github.com/opdev/virtwork/internal/config" + "github.com/opdev/virtwork/internal/constants" + "github.com/opdev/virtwork/internal/vm" + + corev1 "k8s.io/api/core/v1" ) var ErrUnknownCatalogRole = errors.New("unknown catalog role") @@ -21,6 +27,8 @@ type GenericMultiWorkload struct { roles []RoleDefinition serviceFiles map[string]string packages []string + storageSpecs []StorageDefinition + serviceDef *ServiceDefinition } // NewGenericMultiWorkload creates a GenericMultiWorkload from a loaded catalog entry. @@ -43,6 +51,8 @@ func NewGenericMultiWorkload( roles: entry.Manifest.Roles, serviceFiles: entry.ServiceFiles, packages: entry.Manifest.Packages, + storageSpecs: entry.Manifest.Storage, + serviceDef: entry.Manifest.Service, } } @@ -91,22 +101,103 @@ func (w *GenericMultiWorkload) UserdataForRole(role string, _ string) (string, e content := w.substituteParams(svcContent) svcFilename := role + ".service" + writeFiles := make([]WriteFile, 0, len(w.storageSpecs)+1) + runcmd := make([][]string, 0, len(w.storageSpecs)+2) + + for _, spec := range w.storageSpecs { + writeFiles = append(writeFiles, WriteFile{ + Path: fmt.Sprintf("/usr/local/bin/virtwork-disk-setup-%s.sh", spec.Name), + Content: diskSetupScript(spec.Serial, spec.Mount), + Permissions: "0755", + }) + runcmd = append(runcmd, []string{ + fmt.Sprintf("/usr/local/bin/virtwork-disk-setup-%s.sh", spec.Name), + }) + } + + writeFiles = append(writeFiles, WriteFile{ + Path: "/etc/systemd/system/" + svcFilename, + Content: content, + Permissions: "0644", + }) + + runcmd = append(runcmd, + []string{"systemctl", "daemon-reload"}, + []string{"systemctl", "enable", "--now", svcFilename}, + ) + 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}, - }, + Packages: w.packages, + WriteFiles: writeFiles, + RunCmd: runcmd, }) } +// DataVolumeTemplates returns CDI DataVolumeTemplateSpecs for declared storage. +func (w *GenericMultiWorkload) DataVolumeTemplates() ([]kubevirtv1.DataVolumeTemplateSpec, error) { + if len(w.storageSpecs) == 0 { + return nil, nil + } + dvts := make([]kubevirtv1.DataVolumeTemplateSpec, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + dvt, err := vm.BuildDataVolumeTemplate(spec.Name, spec.Size) + if err != nil { + return nil, fmt.Errorf("building data volume for storage %q: %w", spec.Name, err) + } + dvts[i] = dvt + } + return dvts, nil +} + +// ExtraDisks returns disk definitions for all declared storage. +func (w *GenericMultiWorkload) ExtraDisks() []kubevirtv1.Disk { + if len(w.storageSpecs) == 0 { + return nil + } + disks := make([]kubevirtv1.Disk, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + disks[i] = kubevirtv1.Disk{ + Name: spec.Name, + Serial: spec.Serial, + DiskDevice: kubevirtv1.DiskDevice{ + Disk: &kubevirtv1.DiskTarget{ + Bus: constants.DiskBusVirtio, + }, + }, + } + } + return disks +} + +// ExtraVolumes returns volume definitions for all declared storage. +func (w *GenericMultiWorkload) ExtraVolumes() []kubevirtv1.Volume { + if len(w.storageSpecs) == 0 { + return nil + } + volumes := make([]kubevirtv1.Volume, len(w.storageSpecs)) + for i, spec := range w.storageSpecs { + volumes[i] = kubevirtv1.Volume{ + Name: spec.Name, + VolumeSource: kubevirtv1.VolumeSource{ + DataVolume: &kubevirtv1.DataVolumeSource{ + Name: spec.Name, + }, + }, + } + } + return volumes +} + +// RequiresService returns true if a service is declared in the manifest. +func (w *GenericMultiWorkload) RequiresService() bool { + return w.serviceDef != nil +} + +// ServiceSpec returns the K8s Service, or nil if not declared. +func (w *GenericMultiWorkload) ServiceSpec() *corev1.Service { + return buildCatalogServiceSpec(w.Name(), w.namespace, w.serviceDef) +} + func (w *GenericMultiWorkload) substituteParams(content string) string { for _, p := range w.ParamSchema { content = strings.ReplaceAll(content, "{{"+p.Key+"}}", w.GetParam(p.Key)) diff --git a/internal/workloads/generic_test.go b/internal/workloads/generic_test.go index 1d209f6..31270ea 100644 --- a/internal/workloads/generic_test.go +++ b/internal/workloads/generic_test.go @@ -59,7 +59,7 @@ WantedBy=multi-user.target CPUCores: 2, Memory: "2Gi", }, - entry, "virtwork", "", nil, + entry, "test-ns", "virtwork", "", nil, ) }) @@ -144,7 +144,7 @@ packages: Expect(err).NotTo(HaveOccurred()) w = workloads.NewGenericWorkload( config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, - entry, "", "", nil, + entry, "test-ns", "", "", nil, ) }) @@ -184,7 +184,7 @@ packages: Expect(err).NotTo(HaveOccurred()) w = workloads.NewGenericWorkload( config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, - entry, "", "", nil, + entry, "test-ns", "", "", nil, ) result, err := w.CloudInitUserdata() Expect(err).NotTo(HaveOccurred()) @@ -204,7 +204,7 @@ packages: "method": "matrixprod", }, }, - entry, "", "", nil, + entry, "test-ns", "", "", nil, ) result, err := w.CloudInitUserdata() Expect(err).NotTo(HaveOccurred()) @@ -221,7 +221,7 @@ packages: Memory: "2Gi", Params: map[string]string{"cpu-load": "90"}, }, - entry, "", "", nil, + entry, "test-ns", "", "", nil, ) result, err := w.CloudInitUserdata() Expect(err).NotTo(HaveOccurred()) @@ -239,7 +239,7 @@ packages: Expect(err).NotTo(HaveOccurred()) w = workloads.NewGenericWorkload( config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, - entry, "", "", nil, + entry, "test-ns", "", "", nil, ) }) From 2c6c51781535c620b0a9b6a0c8f2eed93c7a7264 Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sat, 13 Jun 2026 17:52:11 -0500 Subject: [PATCH 2/3] test: add storage/service tests for GenericWorkload and GenericMultiWorkload Cover DataVolumeTemplates, ExtraDisks, ExtraVolumes, RequiresService, ServiceSpec, cloud-init disk setup script injection, runcmd ordering, selector-role routing, backward compatibility, and combined storage+service. Signed-off-by: Melvin Hillsman --- internal/workloads/catalog.go | 24 +- internal/workloads/generic_multi_test.go | 289 +++++++++++++++++++++++ internal/workloads/generic_test.go | 266 +++++++++++++++++++++ 3 files changed, 567 insertions(+), 12 deletions(-) diff --git a/internal/workloads/catalog.go b/internal/workloads/catalog.go index 9df57d2..06d0085 100644 --- a/internal/workloads/catalog.go +++ b/internal/workloads/catalog.go @@ -11,11 +11,11 @@ import ( "sort" "strings" + "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" - "gopkg.in/yaml.v3" "github.com/opdev/virtwork/internal/config" "github.com/opdev/virtwork/internal/constants" @@ -27,16 +27,16 @@ var ( ErrCatalogManifestRequired = errors.New("workload.yaml is required for multi-role catalog entries") ErrCatalogMissingRoleService = errors.New("missing .service file for declared role") - ErrStorageNameEmpty = errors.New("storage name must not be empty") - ErrStorageNameReserved = errors.New("storage name conflicts with reserved disk name") - ErrStorageNameDuplicate = errors.New("duplicate storage name") - ErrStorageSizeInvalid = errors.New("storage size must be a valid quantity") - ErrStorageSerialEmpty = errors.New("storage serial must not be empty") - ErrStorageSerialTooLong = errors.New("storage serial must be at most 20 characters") + ErrStorageNameEmpty = errors.New("storage name must not be empty") + ErrStorageNameReserved = errors.New("storage name conflicts with reserved disk name") + ErrStorageNameDuplicate = errors.New("duplicate storage name") + ErrStorageSizeInvalid = errors.New("storage size must be a valid quantity") + ErrStorageSerialEmpty = errors.New("storage serial must not be empty") + ErrStorageSerialTooLong = errors.New("storage serial must be at most 20 characters") ErrStorageMountNotAbsolute = errors.New("storage mount must be an absolute path") - ErrServicePortsEmpty = errors.New("service must declare at least one port") - ErrServicePortRange = errors.New("service port must be between 1 and 65535") - ErrServiceProtocol = errors.New("service protocol must be TCP or UDP") + ErrServicePortsEmpty = errors.New("service must declare at least one port") + ErrServicePortRange = errors.New("service port must be between 1 and 65535") + ErrServiceProtocol = errors.New("service protocol must be TCP or UDP") ) // RoleDefinition declares a role and its default VM count in a catalog manifest. @@ -147,8 +147,8 @@ func (e *CatalogEntry) Factory() WorkloadFactory { var reservedDiskNames = map[string]bool{ "containerdisk": true, - "cloudinitdisk": true, - "datadisk": true, + "cloudinitdisk": true, + "datadisk": true, } func validateManifest(m *CatalogManifest) error { diff --git a/internal/workloads/generic_multi_test.go b/internal/workloads/generic_multi_test.go index 600f58e..c8a3001 100644 --- a/internal/workloads/generic_multi_test.go +++ b/internal/workloads/generic_multi_test.go @@ -285,4 +285,293 @@ roles: var _ workloads.MultiVMWorkload = multi }) }) + + Context("multi-role entry with storage declared", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "storage-multi") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +storage: + - name: data + size: 10Gi + serial: vw-data + mount: /mnt/data + - name: logs + size: 5Gi + serial: vw-logs + mount: /var/log/app +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/server\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/client\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "storage-multi") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should return DataVolumeTemplates for all storage entries", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(HaveLen(2)) + Expect(dvts[0].Name).To(Equal("data")) + Expect(dvts[1].Name).To(Equal("logs")) + }) + + It("should return ExtraDisks with correct serials", func() { + disks := w.ExtraDisks() + Expect(disks).To(HaveLen(2)) + Expect(disks[0].Name).To(Equal("data")) + Expect(disks[0].Serial).To(Equal("vw-data")) + Expect(string(disks[0].DiskDevice.Disk.Bus)).To(Equal("virtio")) + Expect(disks[1].Name).To(Equal("logs")) + Expect(disks[1].Serial).To(Equal("vw-logs")) + }) + + It("should return ExtraVolumes referencing DataVolumes", func() { + volumes := w.ExtraVolumes() + Expect(volumes).To(HaveLen(2)) + Expect(volumes[0].Name).To(Equal("data")) + Expect(volumes[0].DataVolume).NotTo(BeNil()) + Expect(volumes[0].DataVolume.Name).To(Equal("data")) + Expect(volumes[1].Name).To(Equal("logs")) + }) + + It("should inject disk setup scripts into every role's userdata", func() { + for _, role := range []string{"server", "client"} { + result, err := w.UserdataForRole(role, "test-ns") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("virtwork-disk-setup-data.sh")) + Expect(result).To(ContainSubstring("virtwork-disk-setup-logs.sh")) + } + }) + + It("should run disk setup scripts before daemon-reload in role userdata", func() { + result, err := w.UserdataForRole("server", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + + var lastSetupIdx, reloadIdx int + for i, cmd := range runcmd { + arr := cmd.([]interface{}) + if len(arr) == 1 && arr[0] == "/usr/local/bin/virtwork-disk-setup-logs.sh" { + lastSetupIdx = i + } + if len(arr) == 2 && arr[0] == "systemctl" && arr[1] == "daemon-reload" { + reloadIdx = i + } + } + Expect(lastSetupIdx).To(BeNumerically("<", reloadIdx), + "disk setup scripts should run before daemon-reload") + }) + + It("should include disk setup script content with correct serial and mount", func() { + result, err := w.UserdataForRole("server", "test-ns") + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + var found bool + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/usr/local/bin/virtwork-disk-setup-data.sh" { + found = true + Expect(fm["permissions"]).To(Equal("0755")) + Expect(fm["content"]).To(ContainSubstring("vw-data")) + Expect(fm["content"]).To(ContainSubstring("/mnt/data")) + } + } + Expect(found).To(BeTrue(), "expected disk setup script in write_files") + }) + + It("should not affect RoleDistribution or VMCount", func() { + dist := w.RoleDistribution() + Expect(dist).To(HaveLen(2)) + Expect(w.VMCount()).To(Equal(3)) + }) + }) + + Context("multi-role entry with service declared", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "svc-multi") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 2 +service: + ports: + - name: iperf + port: 5201 + protocol: TCP + selector-role: server +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/server\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/client\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "svc-multi") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should require a service", func() { + Expect(w.RequiresService()).To(BeTrue()) + }) + + It("should return a ServiceSpec with correct name and namespace", func() { + svc := w.ServiceSpec() + Expect(svc).NotTo(BeNil()) + Expect(svc.Name).To(Equal("virtwork-svc-multi")) + Expect(svc.Namespace).To(Equal("test-ns")) + }) + + It("should use selector-role for the service selector", func() { + svc := w.ServiceSpec() + Expect(svc.Spec.Selector).To(HaveKeyWithValue("virtwork/role", "server")) + Expect(svc.Spec.Selector).NotTo(HaveKey("app.kubernetes.io/name")) + }) + + It("should include all declared ports", func() { + svc := w.ServiceSpec() + Expect(svc.Spec.Ports).To(HaveLen(1)) + Expect(svc.Spec.Ports[0].Name).To(Equal("iperf")) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(5201))) + Expect(string(svc.Spec.Ports[0].Protocol)).To(Equal("TCP")) + }) + + It("should set standard labels on the service", func() { + svc := w.ServiceSpec() + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "virtwork")) + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "virtwork")) + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/component", "svc-multi")) + }) + }) + + Context("multi-role entry with service but no selector-role", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "svc-no-role") + writeFile(entryDir, "workload.yaml", `roles: + - name: server + vm-count: 1 + - name: client + vm-count: 1 +service: + ports: + - name: http + port: 8080 + protocol: TCP +`) + writeFile(entryDir, "server.service", "[Service]\nExecStart=/bin/server\n") + writeFile(entryDir, "client.service", "[Service]\nExecStart=/bin/client\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "svc-no-role") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should use app label as selector when no selector-role", func() { + svc := w.ServiceSpec() + Expect(svc.Spec.Selector).To(HaveKeyWithValue( + "app.kubernetes.io/name", "virtwork-svc-no-role")) + Expect(svc.Spec.Selector).NotTo(HaveKey("virtwork/role")) + }) + }) + + Context("multi-role entry without storage or service", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "plain-multi") + 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, "plain-multi") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should not require a service", func() { + Expect(w.RequiresService()).To(BeFalse()) + }) + + It("should return nil ServiceSpec", func() { + Expect(w.ServiceSpec()).To(BeNil()) + }) + + It("should return nil for all storage methods", func() { + Expect(w.ExtraVolumes()).To(BeNil()) + Expect(w.ExtraDisks()).To(BeNil()) + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(BeNil()) + }) + }) + + Context("multi-role entry with both storage and service", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "full-multi") + writeFile(entryDir, "workload.yaml", `roles: + - name: primary + vm-count: 1 + - name: replica + vm-count: 2 +packages: + - postgresql +storage: + - name: pgdata + size: 20Gi + serial: vw-pgdata + mount: /var/lib/pgsql +service: + ports: + - name: postgres + port: 5432 + protocol: TCP + selector-role: primary +`) + writeFile(entryDir, "primary.service", "[Service]\nExecStart=/bin/pg-primary\n") + writeFile(entryDir, "replica.service", "[Service]\nExecStart=/bin/pg-replica\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "full-multi") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericMultiWorkload( + config.WorkloadConfig{CPUCores: 4, Memory: "4Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should support both storage and service simultaneously", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(HaveLen(1)) + + Expect(w.RequiresService()).To(BeTrue()) + svc := w.ServiceSpec() + Expect(svc.Spec.Ports).To(HaveLen(1)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(5432))) + Expect(svc.Spec.Selector).To(HaveKeyWithValue("virtwork/role", "primary")) + }) + + It("should include disk setup in all roles' userdata", func() { + for _, role := range []string{"primary", "replica"} { + result, err := w.UserdataForRole(role, "test-ns") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("virtwork-disk-setup-pgdata.sh")) + Expect(result).To(ContainSubstring("postgresql")) + } + }) + }) }) diff --git a/internal/workloads/generic_test.go b/internal/workloads/generic_test.go index 31270ea..38ef391 100644 --- a/internal/workloads/generic_test.go +++ b/internal/workloads/generic_test.go @@ -270,4 +270,270 @@ packages: )) }) }) + + Context("entry with storage declared", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "with-storage") + writeFile(entryDir, "workload.yaml", `description: "Storage workload" +storage: + - name: data + size: 10Gi + serial: vw-data + mount: /mnt/data +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "with-storage") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should return DataVolumeTemplates for declared storage", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(HaveLen(1)) + Expect(dvts[0].Name).To(Equal("data")) + }) + + It("should return ExtraDisks with correct serial and bus", func() { + disks := w.ExtraDisks() + Expect(disks).To(HaveLen(1)) + Expect(disks[0].Name).To(Equal("data")) + Expect(disks[0].Serial).To(Equal("vw-data")) + Expect(disks[0].DiskDevice.Disk).NotTo(BeNil()) + Expect(string(disks[0].DiskDevice.Disk.Bus)).To(Equal("virtio")) + }) + + It("should return ExtraVolumes referencing the DataVolume", func() { + volumes := w.ExtraVolumes() + Expect(volumes).To(HaveLen(1)) + Expect(volumes[0].Name).To(Equal("data")) + Expect(volumes[0].DataVolume).NotTo(BeNil()) + Expect(volumes[0].DataVolume.Name).To(Equal("data")) + }) + + It("should inject disk setup script into cloud-init write_files", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + files := parsed["write_files"].([]interface{}) + var setupScript map[string]interface{} + for _, f := range files { + fm := f.(map[string]interface{}) + if fm["path"] == "/usr/local/bin/virtwork-disk-setup-data.sh" { + setupScript = fm + } + } + Expect(setupScript).NotTo(BeNil(), "expected disk setup script in write_files") + Expect(setupScript["permissions"]).To(Equal("0755")) + Expect(setupScript["content"]).To(ContainSubstring("vw-data")) + Expect(setupScript["content"]).To(ContainSubstring("/mnt/data")) + }) + + It("should run disk setup script before daemon-reload in runcmd", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + parsed := parseYAML(result) + runcmd := parsed["runcmd"].([]interface{}) + + var setupIdx, reloadIdx int + for i, cmd := range runcmd { + arr := cmd.([]interface{}) + if len(arr) == 1 && arr[0] == "/usr/local/bin/virtwork-disk-setup-data.sh" { + setupIdx = i + } + if len(arr) == 2 && arr[0] == "systemctl" && arr[1] == "daemon-reload" { + reloadIdx = i + } + } + Expect(setupIdx).To(BeNumerically("<", reloadIdx), + "disk setup script should run before daemon-reload") + }) + }) + + Context("entry with multiple storage definitions", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "multi-storage") + writeFile(entryDir, "workload.yaml", `storage: + - name: data + size: 10Gi + serial: vw-data + mount: /mnt/data + - name: logs + size: 5Gi + serial: vw-logs + mount: /var/log/app +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "multi-storage") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should return DataVolumeTemplates for all storage entries", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(HaveLen(2)) + Expect(dvts[0].Name).To(Equal("data")) + Expect(dvts[1].Name).To(Equal("logs")) + }) + + It("should return ExtraDisks for all storage entries", func() { + disks := w.ExtraDisks() + Expect(disks).To(HaveLen(2)) + Expect(disks[0].Serial).To(Equal("vw-data")) + Expect(disks[1].Serial).To(Equal("vw-logs")) + }) + + It("should return ExtraVolumes for all storage entries", func() { + volumes := w.ExtraVolumes() + Expect(volumes).To(HaveLen(2)) + Expect(volumes[0].Name).To(Equal("data")) + Expect(volumes[1].Name).To(Equal("logs")) + }) + + It("should inject disk setup scripts for each storage entry", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("virtwork-disk-setup-data.sh")) + Expect(result).To(ContainSubstring("virtwork-disk-setup-logs.sh")) + }) + }) + + Context("entry with service declared", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "with-svc-def") + writeFile(entryDir, "workload.yaml", `description: "Service workload" +service: + ports: + - name: http + port: 8080 + protocol: TCP + - name: metrics + port: 9090 + protocol: UDP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "with-svc-def") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should require a service", func() { + Expect(w.RequiresService()).To(BeTrue()) + }) + + It("should return a ServiceSpec with correct name and namespace", func() { + svc := w.ServiceSpec() + Expect(svc).NotTo(BeNil()) + Expect(svc.Name).To(Equal("virtwork-with-svc-def")) + Expect(svc.Namespace).To(Equal("test-ns")) + }) + + It("should set standard labels on the service", func() { + svc := w.ServiceSpec() + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "virtwork")) + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "virtwork")) + Expect(svc.Labels).To(HaveKeyWithValue("app.kubernetes.io/component", "with-svc-def")) + }) + + It("should use app label as selector for single-role", func() { + svc := w.ServiceSpec() + Expect(svc.Spec.Selector).To(HaveKeyWithValue( + "app.kubernetes.io/name", "virtwork-with-svc-def")) + }) + + It("should include all declared ports", func() { + svc := w.ServiceSpec() + Expect(svc.Spec.Ports).To(HaveLen(2)) + Expect(svc.Spec.Ports[0].Name).To(Equal("http")) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(8080))) + Expect(string(svc.Spec.Ports[0].Protocol)).To(Equal("TCP")) + Expect(svc.Spec.Ports[1].Name).To(Equal("metrics")) + Expect(svc.Spec.Ports[1].Port).To(Equal(int32(9090))) + Expect(string(svc.Spec.Ports[1].Protocol)).To(Equal("UDP")) + }) + }) + + Context("entry without service or storage", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "plain") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/app\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "plain") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 2, Memory: "2Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should not require a service", func() { + Expect(w.RequiresService()).To(BeFalse()) + }) + + It("should return nil ServiceSpec", func() { + Expect(w.ServiceSpec()).To(BeNil()) + }) + + It("should return nil for all storage methods", func() { + Expect(w.ExtraVolumes()).To(BeNil()) + Expect(w.ExtraDisks()).To(BeNil()) + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(BeNil()) + }) + }) + + Context("entry with both storage and service", func() { + BeforeEach(func() { + entryDir := filepath.Join(catalogDir, "full") + writeFile(entryDir, "workload.yaml", `description: "Full featured" +packages: + - postgresql +storage: + - name: pgdata + size: 20Gi + serial: vw-pgdata + mount: /var/lib/pgsql +service: + ports: + - name: postgres + port: 5432 + protocol: TCP +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/pg\n") + entry, err := workloads.LoadCatalogEntry(catalogDir, "full") + Expect(err).NotTo(HaveOccurred()) + w = workloads.NewGenericWorkload( + config.WorkloadConfig{CPUCores: 4, Memory: "4Gi"}, + entry, "test-ns", "", "", nil, + ) + }) + + It("should support both storage and service simultaneously", func() { + dvts, err := w.DataVolumeTemplates() + Expect(err).NotTo(HaveOccurred()) + Expect(dvts).To(HaveLen(1)) + + Expect(w.RequiresService()).To(BeTrue()) + svc := w.ServiceSpec() + Expect(svc.Spec.Ports).To(HaveLen(1)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(5432))) + }) + + It("should include both disk setup and service in cloud-init", func() { + result, err := w.CloudInitUserdata() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(ContainSubstring("virtwork-disk-setup-pgdata.sh")) + Expect(result).To(ContainSubstring("postgresql")) + }) + }) }) From 653484f51c65953feed87b639470bba3df9714b4 Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sat, 13 Jun 2026 18:54:49 -0500 Subject: [PATCH 3/3] chore: resolve package imports failing lint Signed-off-by: Melvin Hillsman --- internal/workloads/generic.go | 3 +-- internal/workloads/generic_multi.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/workloads/generic.go b/internal/workloads/generic.go index a6329d0..f81b4e2 100644 --- a/internal/workloads/generic.go +++ b/internal/workloads/generic.go @@ -8,13 +8,12 @@ import ( "sort" "strings" + corev1 "k8s.io/api/core/v1" kubevirtv1 "kubevirt.io/api/core/v1" "github.com/opdev/virtwork/internal/config" "github.com/opdev/virtwork/internal/constants" "github.com/opdev/virtwork/internal/vm" - - corev1 "k8s.io/api/core/v1" ) // GenericWorkload implements Workload for single-role catalog entries. diff --git a/internal/workloads/generic_multi.go b/internal/workloads/generic_multi.go index c6e4e6b..cd0218f 100644 --- a/internal/workloads/generic_multi.go +++ b/internal/workloads/generic_multi.go @@ -8,13 +8,12 @@ import ( "fmt" "strings" + corev1 "k8s.io/api/core/v1" kubevirtv1 "kubevirt.io/api/core/v1" "github.com/opdev/virtwork/internal/config" "github.com/opdev/virtwork/internal/constants" "github.com/opdev/virtwork/internal/vm" - - corev1 "k8s.io/api/core/v1" ) var ErrUnknownCatalogRole = errors.New("unknown catalog role")