diff --git a/osac-operator/api/v1alpha1/computeinstance_types.go b/osac-operator/api/v1alpha1/computeinstance_types.go index 6d4d7a116c..2a111686f7 100644 --- a/osac-operator/api/v1alpha1/computeinstance_types.go +++ b/osac-operator/api/v1alpha1/computeinstance_types.go @@ -60,6 +60,25 @@ type DiskSpec struct { StorageTier string `json:"storageTier,omitempty"` } +// GpuSpec defines GPU passthrough configuration resolved from the InstanceType. +type GpuSpec struct { + // PciDeviceSelector is the PCI vendor:device ID (e.g., "10DE:20B0"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + PciDeviceSelector string `json:"pciDeviceSelector"` + + // ResourceName is the Kubernetes device plugin resource (e.g., "nvidia.com/A100"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ResourceName string `json:"resourceName"` + + // Count is the number of GPU devices of this type. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=16 + Count int32 `json:"count"` +} + // RunStrategyType defines valid VM run strategies // +kubebuilder:validation:Enum=Always;Halted type RunStrategyType string @@ -88,6 +107,7 @@ type NetworkAttachment struct { } // ComputeInstanceSpec defines the desired state of ComputeInstance +// +kubebuilder:validation:XValidation:rule="has(self.gpu) == has(oldSelf.gpu) && (!has(self.gpu) || self.gpu == oldSelf.gpu)",message="gpu is immutable" type ComputeInstanceSpec struct { // TemplateID is the unique identifier of the compute instance template to use when creating this compute instance // +kubebuilder:validation:Required @@ -137,6 +157,10 @@ type ComputeInstanceSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="additionalDisks is immutable" AdditionalDisks []DiskSpec `json:"additionalDisks,omitempty"` + // Gpu defines GPU passthrough configuration resolved from the InstanceType. + // +kubebuilder:validation:Optional + Gpu *GpuSpec `json:"gpu,omitempty"` + // RunStrategy controls VM running state (MUTABLE) // +kubebuilder:validation:Required RunStrategy RunStrategyType `json:"runStrategy"` diff --git a/osac-operator/api/v1alpha1/computeinstance_types_test.go b/osac-operator/api/v1alpha1/computeinstance_types_test.go index 0c0971fb70..73f0b2f4c7 100644 --- a/osac-operator/api/v1alpha1/computeinstance_types_test.go +++ b/osac-operator/api/v1alpha1/computeinstance_types_test.go @@ -106,6 +106,30 @@ var _ = Describe("ComputeInstanceSpec", func() { Expect(spec.UserDataSecretRef.Name).To(Equal("my-cloud-init")) }) + It("should support GPU spec", func() { + spec := v1alpha1.ComputeInstanceSpec{ + TemplateID: "test-template", + Image: v1alpha1.ImageSpec{ + SourceType: v1alpha1.ImageSourceTypeRegistry, + SourceRef: "test-image:latest", + }, + Cores: 8, + MemoryGiB: 64, + BootDisk: v1alpha1.DiskSpec{SizeGiB: 100}, + Gpu: &v1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 2, + }, + RunStrategy: v1alpha1.RunStrategyAlways, + } + + Expect(spec.Gpu).ToNot(BeNil()) + Expect(spec.Gpu.PciDeviceSelector).To(Equal("10DE:20B0")) + Expect(spec.Gpu.ResourceName).To(Equal("nvidia.com/A100")) + Expect(spec.Gpu.Count).To(Equal(int32(2))) + }) + It("should support SSH key", func() { sshKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..." spec := v1alpha1.ComputeInstanceSpec{ diff --git a/osac-operator/api/v1alpha1/zz_generated.deepcopy.go b/osac-operator/api/v1alpha1/zz_generated.deepcopy.go index 101281d8b7..96d02c02d1 100644 --- a/osac-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/osac-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -270,6 +270,11 @@ func (in *ComputeInstanceSpec) DeepCopyInto(out *ComputeInstanceSpec) { *out = make([]DiskSpec, len(*in)) copy(*out, *in) } + if in.Gpu != nil { + in, out := &in.Gpu, &out.Gpu + *out = new(GpuSpec) + **out = **in + } if in.UserDataSecretRef != nil { in, out := &in.UserDataSecretRef, &out.UserDataSecretRef *out = new(corev1.LocalObjectReference) @@ -690,6 +695,21 @@ func (in *ExternalIPStatus) DeepCopy() *ExternalIPStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GpuSpec) DeepCopyInto(out *GpuSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GpuSpec. +func (in *GpuSpec) DeepCopy() *GpuSpec { + if in == nil { + return nil + } + out := new(GpuSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ImageSpec) DeepCopyInto(out *ImageSpec) { *out = *in diff --git a/osac-operator/charts/operator-crds/templates/osac.openshift.io_computeinstances.yaml b/osac-operator/charts/operator-crds/templates/osac.openshift.io_computeinstances.yaml index 308688a23c..bf322fb400 100644 --- a/osac-operator/charts/operator-crds/templates/osac.openshift.io_computeinstances.yaml +++ b/osac-operator/charts/operator-crds/templates/osac.openshift.io_computeinstances.yaml @@ -121,6 +121,31 @@ spec: x-kubernetes-validations: - message: cores is immutable rule: self == oldSelf + gpu: + description: Gpu defines GPU passthrough configuration resolved from + the InstanceType. + properties: + count: + description: Count is the number of GPU devices of this type. + format: int32 + maximum: 16 + minimum: 1 + type: integer + pciDeviceSelector: + description: PciDeviceSelector is the PCI vendor:device ID (e.g., + "10DE:20B0"). + minLength: 1 + type: string + resourceName: + description: ResourceName is the Kubernetes device plugin resource + (e.g., "nvidia.com/A100"). + minLength: 1 + type: string + required: + - count + - pciDeviceSelector + - resourceName + type: object guestOSFamily: description: |- GuestOSFamily specifies the guest operating system family for the VM. @@ -281,6 +306,10 @@ spec: - runStrategy - templateID type: object + x-kubernetes-validations: + - message: gpu is immutable + rule: has(self.gpu) == has(oldSelf.gpu) && (!has(self.gpu) || self.gpu + == oldSelf.gpu) status: description: status defines the observed state of ComputeInstance properties: diff --git a/osac-operator/config/crd/bases/osac.openshift.io_computeinstances.yaml b/osac-operator/config/crd/bases/osac.openshift.io_computeinstances.yaml index 87ab960c07..99d07e73d7 100644 --- a/osac-operator/config/crd/bases/osac.openshift.io_computeinstances.yaml +++ b/osac-operator/config/crd/bases/osac.openshift.io_computeinstances.yaml @@ -119,6 +119,31 @@ spec: x-kubernetes-validations: - message: cores is immutable rule: self == oldSelf + gpu: + description: Gpu defines GPU passthrough configuration resolved from + the InstanceType. + properties: + count: + description: Count is the number of GPU devices of this type. + format: int32 + maximum: 16 + minimum: 1 + type: integer + pciDeviceSelector: + description: PciDeviceSelector is the PCI vendor:device ID (e.g., + "10DE:20B0"). + minLength: 1 + type: string + resourceName: + description: ResourceName is the Kubernetes device plugin resource + (e.g., "nvidia.com/A100"). + minLength: 1 + type: string + required: + - count + - pciDeviceSelector + - resourceName + type: object guestOSFamily: description: |- GuestOSFamily specifies the guest operating system family for the VM. @@ -279,6 +304,10 @@ spec: - runStrategy - templateID type: object + x-kubernetes-validations: + - message: gpu is immutable + rule: has(self.gpu) == has(oldSelf.gpu) && (!has(self.gpu) || self.gpu + == oldSelf.gpu) status: description: status defines the observed state of ComputeInstance properties: diff --git a/osac-operator/internal/controller/computeinstance_validation_test.go b/osac-operator/internal/controller/computeinstance_validation_test.go index 96847ba3b5..c2e3ef0deb 100644 --- a/osac-operator/internal/controller/computeinstance_validation_test.go +++ b/osac-operator/internal/controller/computeinstance_validation_test.go @@ -488,6 +488,120 @@ var _ = Describe("ComputeInstance CEL Validation", func() { }) }) + Describe("GPU validation", func() { + It("should allow creation with valid GPU spec", func() { + instance := createValidInstance("test-gpu-valid") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 1, + } + + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + }) + + It("should allow creation without GPU spec", func() { + instance := createValidInstance("test-gpu-none") + + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + }) + + It("should omit gpu from JSON when not set (omitempty)", func() { + instance := createValidInstance("test-gpu-omitempty") + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + Expect(instance.Spec.Gpu).To(BeNil()) + }) + + It("should reject GPU with count below minimum", func() { + instance := createValidInstance("test-gpu-count-zero") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 0, + } + + err := k8sClient.Create(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should reject GPU with count above maximum", func() { + instance := createValidInstance("test-gpu-count-max") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 17, + } + + err := k8sClient.Create(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should reject GPU with empty pciDeviceSelector", func() { + instance := createValidInstance("test-gpu-empty-pci") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "", + ResourceName: "nvidia.com/A100", + Count: 1, + } + + err := k8sClient.Create(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should reject GPU with empty resourceName", func() { + instance := createValidInstance("test-gpu-empty-rn") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "", + Count: 1, + } + + err := k8sClient.Create(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should reject changing gpu", func() { + instance := createValidInstance("test-gpu-immutable") + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 1, + } + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + + instance.Spec.Gpu.Count = 2 + err := k8sClient.Update(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("gpu is immutable")) + }) + + It("should reject adding gpu after creation", func() { + instance := createValidInstance("test-gpu-add-after") + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + + instance.Spec.Gpu = &osacv1alpha1.GpuSpec{ + PciDeviceSelector: "10DE:20B0", + ResourceName: "nvidia.com/A100", + Count: 1, + } + err := k8sClient.Update(ctx, instance) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("gpu is immutable")) + }) + }) + Describe("MaxItems validation", func() { It("should reject creating ComputeInstance with more than 8 networkAttachments", func() { instance := createValidInstance("test-max-attachments")