diff --git a/internal/workloads/catalog.go b/internal/workloads/catalog.go index c310c20..06d0085 100644 --- a/internal/workloads/catalog.go +++ b/internal/workloads/catalog.go @@ -12,8 +12,13 @@ import ( "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" "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..f81b4e2 100644 --- a/internal/workloads/generic.go +++ b/internal/workloads/generic.go @@ -4,25 +4,34 @@ package workloads import ( + "fmt" "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" ) // 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 +43,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 +64,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 +97,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..cd0218f 100644 --- a/internal/workloads/generic_multi.go +++ b/internal/workloads/generic_multi.go @@ -8,7 +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" ) var ErrUnknownCatalogRole = errors.New("unknown catalog role") @@ -21,6 +26,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 +50,8 @@ func NewGenericMultiWorkload( roles: entry.Manifest.Roles, serviceFiles: entry.ServiceFiles, packages: entry.Manifest.Packages, + storageSpecs: entry.Manifest.Storage, + serviceDef: entry.Manifest.Service, } } @@ -91,22 +100,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_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 1d209f6..38ef391 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, ) }) @@ -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")) + }) + }) })