diff --git a/controller/api/virtualtarget/v1alpha1/exporterset_types.go b/controller/api/virtualtarget/v1alpha1/exporterset_types.go index c44947cae..0e98749d5 100644 --- a/controller/api/virtualtarget/v1alpha1/exporterset_types.go +++ b/controller/api/virtualtarget/v1alpha1/exporterset_types.go @@ -118,6 +118,12 @@ type ExporterSetSpec struct { // +optional Images *ImageOverrides `json:"images,omitempty"` + // StorageClassName overrides VirtualTargetClass.spec.storageClassName for + // guest disk volumes. When nil, the class value is used. When set to an + // empty string, forces emptyDir even if the class names a StorageClass. + // +optional + StorageClassName *string `json:"storageClassName,omitempty"` + // Selector defines the label selector for matching exporters owned by this set. Selector metav1.LabelSelector `json:"selector"` diff --git a/controller/api/virtualtarget/v1alpha1/storage.go b/controller/api/virtualtarget/v1alpha1/storage.go new file mode 100644 index 000000000..6716aac85 --- /dev/null +++ b/controller/api/virtualtarget/v1alpha1/storage.go @@ -0,0 +1,30 @@ +/* +Copyright 2026 The Jumpstarter Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +// EffectiveStorageClassName returns the StorageClass to use for guest disk +// volumes. ExporterSet.spec.storageClassName overrides the class when set +// (including the empty string to force emptyDir). +func EffectiveStorageClassName(vtc *VirtualTargetClass, es *ExporterSet) string { + if es != nil && es.Spec.StorageClassName != nil { + return *es.Spec.StorageClassName + } + if vtc != nil { + return vtc.Spec.StorageClassName + } + return "" +} diff --git a/controller/api/virtualtarget/v1alpha1/storage_test.go b/controller/api/virtualtarget/v1alpha1/storage_test.go new file mode 100644 index 000000000..47d6169e7 --- /dev/null +++ b/controller/api/virtualtarget/v1alpha1/storage_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2026 The Jumpstarter Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "testing" +) + +func TestEffectiveStorageClassName(t *testing.T) { + empty := "" + override := "es-sc" + + tests := []struct { + name string + vtc string + es *string + want string + }{ + {name: "both empty", want: ""}, + {name: "vtc only", vtc: "vtc-sc", want: "vtc-sc"}, + {name: "es override", vtc: "vtc-sc", es: &override, want: "es-sc"}, + {name: "es clears to emptyDir", vtc: "vtc-sc", es: &empty, want: ""}, + {name: "es only", es: &override, want: "es-sc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vtc := &VirtualTargetClass{Spec: VirtualTargetClassSpec{StorageClassName: tt.vtc}} + es := &ExporterSet{Spec: ExporterSetSpec{StorageClassName: tt.es}} + if got := EffectiveStorageClassName(vtc, es); got != tt.want { + t.Errorf("EffectiveStorageClassName() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/controller/api/virtualtarget/v1alpha1/virtualtargetclass_types.go b/controller/api/virtualtarget/v1alpha1/virtualtargetclass_types.go index e0f3de6bb..2d2dc99a3 100644 --- a/controller/api/virtualtarget/v1alpha1/virtualtargetclass_types.go +++ b/controller/api/virtualtarget/v1alpha1/virtualtargetclass_types.go @@ -141,6 +141,14 @@ type VirtualTargetClassSpec struct { // ExporterSet-level images take precedence over these class-level defaults. // +optional Images *ImageOverrides `json:"images,omitempty"` + + // StorageClassName selects the StorageClass for the guest disk PVC. + // When empty, the provisioner uses an emptyDir volume sized from + // parameters.resources.storage and sets ephemeral-storage + // requests/limits so the scheduler accounts for local disk usage. + // ExporterSet.spec.storageClassName can override this value. + // +optional + StorageClassName string `json:"storageClassName,omitempty"` } // +kubebuilder:object:root=true diff --git a/controller/api/virtualtarget/v1alpha1/zz_generated.deepcopy.go b/controller/api/virtualtarget/v1alpha1/zz_generated.deepcopy.go index a8485d19b..ed7971c0f 100644 --- a/controller/api/virtualtarget/v1alpha1/zz_generated.deepcopy.go +++ b/controller/api/virtualtarget/v1alpha1/zz_generated.deepcopy.go @@ -168,6 +168,11 @@ func (in *ExporterSetSpec) DeepCopyInto(out *ExporterSetSpec) { *out = new(ImageOverrides) (*in).DeepCopyInto(*out) } + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } in.Selector.DeepCopyInto(&out.Selector) in.Template.DeepCopyInto(&out.Template) } diff --git a/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_exportersets.yaml b/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_exportersets.yaml index 1318e2808..72015d329 100644 --- a/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_exportersets.yaml +++ b/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_exportersets.yaml @@ -186,6 +186,12 @@ spec: type: object type: object x-kubernetes-map-type: atomic + storageClassName: + description: |- + StorageClassName overrides VirtualTargetClass.spec.storageClassName for + guest disk volumes. When nil, the class value is used. When set to an + empty string, forces emptyDir even if the class names a StorageClass. + type: string template: description: Template defines the exporter template for instances created by this set. @@ -218,15 +224,15 @@ spec: description: Config holds driver-specific configuration. x-kubernetes-preserve-unknown-fields: true name: - description: |- - Name is the key used in the ExporterConfig export map. - If omitted, derived from the Type (last segment after the last dot). + description: Name is the key used in the ExporterConfig + export map. type: string type: description: Type is the fully qualified Python driver class name. type: string required: + - name - type type: object type: array diff --git a/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_virtualtargetclasses.yaml b/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_virtualtargetclasses.yaml index 598887b6c..01e191e78 100644 --- a/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_virtualtargetclasses.yaml +++ b/controller/deploy/operator/config/crd/bases/virtualtarget.jumpstarter.dev_virtualtargetclasses.yaml @@ -262,6 +262,14 @@ spec: type: object type: array type: object + storageClassName: + description: |- + StorageClassName selects the StorageClass for the guest disk PVC. + When empty, the provisioner uses an emptyDir volume sized from + parameters.resources.storage and sets ephemeral-storage + requests/limits so the scheduler accounts for local disk usage. + ExporterSet.spec.storageClassName can override this value. + type: string required: - provisioner type: object diff --git a/controller/deploy/operator/internal/controller/jumpstarter/exporterset.go b/controller/deploy/operator/internal/controller/jumpstarter/exporterset.go index 46a842cc3..7e14d2505 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/exporterset.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/exporterset.go @@ -522,6 +522,11 @@ func exporterSetPolicyRules() []rbacv1.PolicyRule { Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, }, + { + APIGroups: []string{""}, + Resources: []string{"persistentvolumeclaims"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, { APIGroups: []string{""}, Resources: []string{"events"}, diff --git a/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go b/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go index e58351745..033a6bb57 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go @@ -181,6 +181,17 @@ var _ = Describe("exporterSetPolicyRules", func() { Fail("no rule found granting full CRUD on pods") }) + It("should grant full CRUD on persistentvolumeclaims", func() { + for _, rule := range rules { + if containsString(rule.APIGroups, "") && + containsString(rule.Resources, "persistentvolumeclaims") { + Expect(rule.Verbs).To(ContainElements("get", "list", "watch", "create", "update", "patch", "delete")) + return + } + } + Fail("no rule found granting full CRUD on persistentvolumeclaims") + }) + It("should grant full CRUD on exporters", func() { for _, rule := range rules { if containsString(rule.APIGroups, "jumpstarter.dev") && diff --git a/controller/hack/sample-x86_64-kind.yaml b/controller/hack/sample-x86_64-kind.yaml index bd935cc52..6b9cbdd7d 100644 --- a/controller/hack/sample-x86_64-kind.yaml +++ b/controller/hack/sample-x86_64-kind.yaml @@ -8,6 +8,12 @@ # acceleration, which is fine for a functional/architecture smoke test # but much slower than a real x86_64+KVM cluster. # +# Guest disk uses emptyDir sized from parameters.resources.storage (no +# storageClassName). The provisioner also sets ephemeral-storage +# requests/limits so the scheduler accounts for local disk. To use a +# PVC instead, set spec.storageClassName on the VirtualTargetClass or +# override it on the ExporterSet. +# # For real deployments with KVM acceleration, use sample-x86_64.yaml # on a cluster that exposes /dev/kvm via the kubevirt device plugin # and taints its KVM-capable nodes with jumpstarter.dev/kvm. diff --git a/controller/hack/sample-x86_64.yaml b/controller/hack/sample-x86_64.yaml index 3e78fbb00..baaf7bd4a 100644 --- a/controller/hack/sample-x86_64.yaml +++ b/controller/hack/sample-x86_64.yaml @@ -7,6 +7,10 @@ # This creates: # - A VirtualTargetClass for x86_64 QEMU VMs with KVM acceleration # - An ExporterSet that manages a pool of virtual exporters +# +# Guest disk: omit storageClassName to use emptyDir (with ephemeral-storage +# accounting), or set storageClassName to provision a per-exporter PVC +# mounted at /disk. ExporterSet.spec.storageClassName overrides the class. --- apiVersion: virtualtarget.jumpstarter.dev/v1alpha1 kind: VirtualTargetClass @@ -17,6 +21,7 @@ spec: provisioner: qemu.jumpstarter.dev bindingMode: Immediate reclaimPolicy: Delete + # storageClassName: "your-storage-class" # optional; omit → emptyDir scheduling: nodeSelector: kubernetes.io/arch: amd64 @@ -50,6 +55,7 @@ spec: scaleDownCooldown: 5m recycleStrategy: ExitAndReplace virtualTargetClassName: qemu-x86-64 + # storageClassName: "override-sc" # optional override ("" forces emptyDir) selector: matchLabels: board: x86-64-virtual diff --git a/controller/internal/exporterset/disk/disk.go b/controller/internal/exporterset/disk/disk.go new file mode 100644 index 000000000..dfa8ba8b5 --- /dev/null +++ b/controller/internal/exporterset/disk/disk.go @@ -0,0 +1,114 @@ +/* +Copyright 2026 The Jumpstarter Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package disk provides shared helpers for guest-disk volume provisioning +// used by ExporterSet provisioners and the reconciler. +package disk + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // VolumeName is the Pod volume name for guest disk storage. + VolumeName = "disk" + + // MountPath is where guest disk storage is mounted in exporter and runtime. + MountPath = "/disk" + + // DefaultSize is used when parameters.resources.storage is unset. + DefaultSize = "10Gi" + + pvcNamePrefix = "disk-" +) + +// PVCName returns the per-exporter PersistentVolumeClaim name. +func PVCName(exporterName string) string { + return pvcNamePrefix + exporterName +} + +// SizeFromParameters reads parameters.resources.storage, defaulting to DefaultSize. +func SizeFromParameters(params map[string]interface{}) (resource.Quantity, error) { + raw := DefaultSize + if params != nil { + if resources, ok := params["resources"].(map[string]interface{}); ok { + switch v := resources["storage"].(type) { + case string: + if v != "" { + raw = v + } + case float64: + // JSON numbers land as float64; treat as Gi if unitless is awkward — + // require string quantities in the API. + return resource.Quantity{}, fmt.Errorf("parameters.resources.storage must be a string quantity (e.g. \"10Gi\"), got number %v", v) + case nil: + // use default + default: + return resource.Quantity{}, fmt.Errorf("parameters.resources.storage must be a string quantity (e.g. \"10Gi\"), got %T", v) + } + } + } + + qty, err := resource.ParseQuantity(raw) + if err != nil { + return resource.Quantity{}, fmt.Errorf("parse parameters.resources.storage %q: %w", raw, err) + } + return qty, nil +} + +// BuildPVC constructs a guest-disk PVC owned by the given exporter metadata. +func BuildPVC(namespace, exporterName, storageClassName string, size resource.Quantity, labels map[string]string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: PVCName(exporterName), + Namespace: namespace, + Labels: labels, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + StorageClassName: &storageClassName, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: size, + }, + }, + }, + } +} + +// SetEphemeralStorage ensures requests and limits include ephemeral-storage +// equal to size (used when guest disk is backed by emptyDir). +func SetEphemeralStorage(resources *corev1.ResourceRequirements, size resource.Quantity) { + if resources.Requests == nil { + resources.Requests = corev1.ResourceList{} + } + if resources.Limits == nil { + resources.Limits = corev1.ResourceList{} + } + // Only set when unset so explicit scheduling.resources win. + if _, ok := resources.Requests[corev1.ResourceEphemeralStorage]; !ok { + resources.Requests[corev1.ResourceEphemeralStorage] = size.DeepCopy() + } + if _, ok := resources.Limits[corev1.ResourceEphemeralStorage]; !ok { + resources.Limits[corev1.ResourceEphemeralStorage] = size.DeepCopy() + } +} diff --git a/controller/internal/exporterset/disk/disk_test.go b/controller/internal/exporterset/disk/disk_test.go new file mode 100644 index 000000000..22e2d1deb --- /dev/null +++ b/controller/internal/exporterset/disk/disk_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2026 The Jumpstarter Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package disk + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestPVCName(t *testing.T) { + if got := PVCName("exp-1"); got != "disk-exp-1" { + t.Errorf("PVCName() = %q, want disk-exp-1", got) + } +} + +func TestSizeFromParameters(t *testing.T) { + qty, err := SizeFromParameters(nil) + if err != nil { + t.Fatalf("nil params: %v", err) + } + if !qty.Equal(resource.MustParse(DefaultSize)) { + t.Errorf("default = %v, want %s", qty, DefaultSize) + } + + qty, err = SizeFromParameters(map[string]interface{}{ + "resources": map[string]interface{}{"storage": "15Gi"}, + }) + if err != nil { + t.Fatalf("15Gi: %v", err) + } + if !qty.Equal(resource.MustParse("15Gi")) { + t.Errorf("got %v, want 15Gi", qty) + } + + _, err = SizeFromParameters(map[string]interface{}{ + "resources": map[string]interface{}{"storage": 10.0}, + }) + if err == nil { + t.Fatal("expected error for numeric storage") + } +} + +func TestSetEphemeralStorage(t *testing.T) { + size := resource.MustParse("10Gi") + res := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + }, + } + SetEphemeralStorage(&res, size) + if !res.Requests[corev1.ResourceEphemeralStorage].Equal(size) { + t.Errorf("request = %v, want %v", res.Requests[corev1.ResourceEphemeralStorage], size) + } + if !res.Limits[corev1.ResourceEphemeralStorage].Equal(size) { + t.Errorf("limit = %v, want %v", res.Limits[corev1.ResourceEphemeralStorage], size) + } + if !res.Requests[corev1.ResourceCPU].Equal(resource.MustParse("1")) { + t.Error("cpu request should be preserved") + } + + // Does not overwrite existing ephemeral-storage. + custom := resource.MustParse("1Gi") + res.Requests[corev1.ResourceEphemeralStorage] = custom + SetEphemeralStorage(&res, size) + if !res.Requests[corev1.ResourceEphemeralStorage].Equal(custom) { + t.Errorf("should preserve explicit ephemeral-storage, got %v", res.Requests[corev1.ResourceEphemeralStorage]) + } +} diff --git a/controller/internal/exporterset/provisioners/qemu/qemu.go b/controller/internal/exporterset/provisioners/qemu/qemu.go index 5b540f41d..3340048d8 100644 --- a/controller/internal/exporterset/provisioners/qemu/qemu.go +++ b/controller/internal/exporterset/provisioners/qemu/qemu.go @@ -30,6 +30,7 @@ import ( jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/api/v1alpha1" virtualtargetv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/api/virtualtarget/v1alpha1" + "github.com/jumpstarter-dev/jumpstarter/controller/internal/exporterset/disk" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -140,9 +141,13 @@ func (p *Provisioner) resolveImageSpec(spec *virtualtargetv1alpha1.ImageSpec, de // - QEMU runtime (main container) runs the virtual machine. // - Shared emptyDir volume for Unix socket communication // (QMP, serial console, launcher socket). +// - Guest disk volume at /disk (PVC when a StorageClass is +// configured, otherwise emptyDir with ephemeral-storage +// requests/limits). // // The caller (reconciler) is responsible for setting -// OwnerReferences on the Pod and injecting the config volume. +// OwnerReferences on the Pod, creating the disk PVC when needed, +// and injecting the config volume. func (p *Provisioner) RenderPod( ctx context.Context, exporterSet *virtualtargetv1alpha1.ExporterSet, @@ -154,6 +159,11 @@ func (p *Provisioner) RenderPod( restartAlways := corev1.ContainerRestartPolicyAlways sizeLimit := resource.MustParse(sharedVolumeSizeLimit) + diskSize, err := disk.SizeFromParameters(mergedParameters) + if err != nil { + return nil, err + } + var exporterSpec, runtimeSpec *virtualtargetv1alpha1.ImageSpec if images != nil { exporterSpec = images.Exporter @@ -176,6 +186,11 @@ func (p *Provisioner) RenderPod( }) } + diskMount := corev1.VolumeMount{ + Name: disk.VolumeName, + MountPath: disk.MountPath, + } + podMeta := metav1.ObjectMeta{ Namespace: exporterSet.Namespace, Labels: maps.Clone(exporterSet.Spec.Template.Metadata.Labels), @@ -224,6 +239,7 @@ func (p *Provisioner) RenderPod( Name: sharedVolumeName, MountPath: sharedMountPath, }, + diskMount, }, }, }, @@ -238,6 +254,7 @@ func (p *Provisioner) RenderPod( Name: sharedVolumeName, MountPath: sharedMountPath, }, + diskMount, }, }, }, @@ -254,6 +271,11 @@ func (p *Provisioner) RenderPod( }, } + storageClass := virtualtargetv1alpha1.EffectiveStorageClassName(vtc, exporterSet) + if err := attachDiskVolume(pod, exporter, storageClass, diskSize); err != nil { + return nil, err + } + // Apply scheduling from VirtualTargetClass. // Clone maps and slices to avoid mutating the VTC's fields. if vtc.Spec.Scheduling != nil { @@ -269,9 +291,52 @@ func (p *Provisioner) RenderPod( } } + // emptyDir guest disks consume node ephemeral storage — ensure the + // scheduler and kubelet account for it on containers that mount /disk. + if storageClass == "" { + disk.SetEphemeralStorage(&pod.Spec.Containers[0].Resources, diskSize) + for i := range pod.Spec.InitContainers { + if pod.Spec.InitContainers[i].Name == "exporter" { + disk.SetEphemeralStorage(&pod.Spec.InitContainers[i].Resources, diskSize) + } + } + } + return pod, nil } +// attachDiskVolume appends the guest disk volume. When storageClass is set, +// the volume references a PVC that the reconciler creates; otherwise an +// emptyDir sized to diskSize is used. +func attachDiskVolume( + pod *corev1.Pod, + exporter *jumpstarterdevv1alpha1.Exporter, + storageClass string, + diskSize resource.Quantity, +) error { + vol := corev1.Volume{Name: disk.VolumeName} + if storageClass != "" { + if exporter == nil { + return fmt.Errorf("disk PVC requires an Exporter to derive the claim name") + } + claimName := disk.PVCName(exporter.Name) + vol.VolumeSource = corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: claimName, + }, + } + } else { + size := diskSize.DeepCopy() + vol.VolumeSource = corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: &size, + }, + } + } + pod.Spec.Volumes = append(pod.Spec.Volumes, vol) + return nil +} + // EnrichExporterExport injects QEMU-specific driver configuration: // - Forces launcher_socket on the QEMU driver entry // - Defaults arch/smp/mem/disk_size from mergedParameters if not set diff --git a/controller/internal/exporterset/provisioners/qemu/qemu_test.go b/controller/internal/exporterset/provisioners/qemu/qemu_test.go index defcef855..a11c45539 100644 --- a/controller/internal/exporterset/provisioners/qemu/qemu_test.go +++ b/controller/internal/exporterset/provisioners/qemu/qemu_test.go @@ -88,17 +88,45 @@ func TestRenderPod_copiesMetadataAndAppliesDefaults(t *testing.T) { t.Errorf("ExporterSet annotations mutated: got %q", got) } - // Only shared volume — config volume is injected by the reconciler. - if len(pod.Spec.Volumes) != 1 { - t.Fatalf("expected 1 volume (shared), got %d", len(pod.Spec.Volumes)) + // Shared emptyDir + guest disk emptyDir (default size). Config volume is + // injected by the reconciler. + if len(pod.Spec.Volumes) != 2 { + t.Fatalf("expected 2 volumes (shared + disk), got %d", len(pod.Spec.Volumes)) } - if pod.Spec.Volumes[0].EmptyDir == nil { - t.Fatal("expected shared emptyDir volume at index 0") + if pod.Spec.Volumes[0].Name != sharedVolumeName || pod.Spec.Volumes[0].EmptyDir == nil { + t.Fatalf("expected shared emptyDir volume at index 0, got %#v", pod.Spec.Volumes[0]) } wantLimit := resource.MustParse(sharedVolumeSizeLimit) if pod.Spec.Volumes[0].EmptyDir.SizeLimit == nil || !pod.Spec.Volumes[0].EmptyDir.SizeLimit.Equal(wantLimit) { - t.Errorf("SizeLimit = %v, want %v", pod.Spec.Volumes[0].EmptyDir.SizeLimit, wantLimit) + t.Errorf("shared SizeLimit = %v, want %v", pod.Spec.Volumes[0].EmptyDir.SizeLimit, wantLimit) + } + if pod.Spec.Volumes[1].Name != "disk" || pod.Spec.Volumes[1].EmptyDir == nil { + t.Fatalf("expected disk emptyDir volume at index 1, got %#v", pod.Spec.Volumes[1]) + } + wantDisk := resource.MustParse("10Gi") + if pod.Spec.Volumes[1].EmptyDir.SizeLimit == nil || + !pod.Spec.Volumes[1].EmptyDir.SizeLimit.Equal(wantDisk) { + t.Errorf("disk SizeLimit = %v, want %v", pod.Spec.Volumes[1].EmptyDir.SizeLimit, wantDisk) + } + + ephemeral := pod.Spec.Containers[0].Resources.Requests[corev1.ResourceEphemeralStorage] + if !ephemeral.Equal(wantDisk) { + t.Errorf("runtime ephemeral-storage request = %v, want %v", ephemeral, wantDisk) + } + exporterEphemeral := pod.Spec.InitContainers[1].Resources.Requests[corev1.ResourceEphemeralStorage] + if !exporterEphemeral.Equal(wantDisk) { + t.Errorf("exporter ephemeral-storage request = %v, want %v", exporterEphemeral, wantDisk) + } + + hasDiskMount := false + for _, m := range pod.Spec.Containers[0].VolumeMounts { + if m.Name == "disk" && m.MountPath == "/disk" { + hasDiskMount = true + } + } + if !hasDiskMount { + t.Error("target-runtime missing /disk mount") } if len(pod.Spec.InitContainers) != 2 { @@ -373,3 +401,75 @@ func TestRenderPod_partialImageOverride(t *testing.T) { t.Errorf("target-runtime image = %q, want %q", pod.Spec.Containers[0].Image, wantRuntime) } } + +func TestRenderPod_diskPVCWhenStorageClassSet(t *testing.T) { + exporterSet := &virtualtargetv1alpha1.ExporterSet{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-set", Namespace: "default"}, + Spec: virtualtargetv1alpha1.ExporterSetSpec{ + StorageClassName: ptr("fast-ssd"), + }, + } + vtc := &virtualtargetv1alpha1.VirtualTargetClass{ + Spec: virtualtargetv1alpha1.VirtualTargetClassSpec{ + Provisioner: ProvisionerName, + StorageClassName: "ignored-because-override", + }, + } + exporter := &jumpstarterdevv1alpha1.Exporter{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-exporter", Namespace: "default"}, + } + params := map[string]interface{}{ + "resources": map[string]interface{}{ + "storage": "20Gi", + }, + } + + pod, err := New("dev").RenderPod(context.Background(), exporterSet, vtc, params, nil, exporter) + if err != nil { + t.Fatalf("RenderPod() error = %v", err) + } + + var diskVol *corev1.Volume + for i := range pod.Spec.Volumes { + if pod.Spec.Volumes[i].Name == "disk" { + diskVol = &pod.Spec.Volumes[i] + break + } + } + if diskVol == nil || diskVol.PersistentVolumeClaim == nil { + t.Fatalf("expected disk PVC volume, got %#v", diskVol) + } + if diskVol.PersistentVolumeClaim.ClaimName != "disk-demo-exporter" { + t.Errorf("ClaimName = %q, want disk-demo-exporter", diskVol.PersistentVolumeClaim.ClaimName) + } + if _, ok := pod.Spec.Containers[0].Resources.Requests[corev1.ResourceEphemeralStorage]; ok { + t.Error("PVC mode should not set ephemeral-storage for guest disk") + } +} + +func TestRenderPod_diskEmptyDirUsesParamSize(t *testing.T) { + exporterSet := &virtualtargetv1alpha1.ExporterSet{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-set", Namespace: "default"}, + } + vtc := &virtualtargetv1alpha1.VirtualTargetClass{ + Spec: virtualtargetv1alpha1.VirtualTargetClassSpec{Provisioner: ProvisionerName}, + } + params := map[string]interface{}{ + "resources": map[string]interface{}{ + "storage": "7Gi", + }, + } + + pod, err := New("dev").RenderPod(context.Background(), exporterSet, vtc, params, nil, nil) + if err != nil { + t.Fatalf("RenderPod() error = %v", err) + } + + want := resource.MustParse("7Gi") + diskVol := pod.Spec.Volumes[1] + if diskVol.EmptyDir == nil || diskVol.EmptyDir.SizeLimit == nil || !diskVol.EmptyDir.SizeLimit.Equal(want) { + t.Errorf("disk SizeLimit = %v, want %v", diskVol.EmptyDir, want) + } +} + +func ptr(s string) *string { return &s } diff --git a/controller/internal/exporterset/reconciler.go b/controller/internal/exporterset/reconciler.go index 5b2e04cc2..5da8cc3dc 100644 --- a/controller/internal/exporterset/reconciler.go +++ b/controller/internal/exporterset/reconciler.go @@ -57,6 +57,7 @@ import ( jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/api/v1alpha1" virtualtargetv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/api/virtualtarget/v1alpha1" + "github.com/jumpstarter-dev/jumpstarter/controller/internal/exporterset/disk" ) const ( @@ -436,6 +437,7 @@ func (r *ExporterSetReconciler) syncConfigSecret( // createExporterPod issues a Pod for a single Exporter that has credentials // but no Pod yet. The config Secret must already exist (syncConfigSecret). +// When a StorageClass is configured, the guest-disk PVC is ensured first. func (r *ExporterSetReconciler) createExporterPod( ctx context.Context, es *virtualtargetv1alpha1.ExporterSet, @@ -446,6 +448,10 @@ func (r *ExporterSetReconciler) createExporterPod( ) error { logger := log.FromContext(ctx) + if err := r.ensureDiskPVC(ctx, es, vtc, mergedParameters, exp); err != nil { + return err + } + pod, err := r.Provisioner.RenderPod(ctx, es, vtc, mergedParameters, images, exp) if err != nil { return fmt.Errorf("render Pod for %s: %w", exp.Name, err) @@ -485,6 +491,62 @@ func (r *ExporterSetReconciler) createExporterPod( return nil } +// ensureDiskPVC creates the guest-disk PVC when a StorageClass is configured. +// No-op for emptyDir mode. The PVC is owned by the Exporter so ExitAndReplace +// cascade deletes it with the instance. +func (r *ExporterSetReconciler) ensureDiskPVC( + ctx context.Context, + es *virtualtargetv1alpha1.ExporterSet, + vtc *virtualtargetv1alpha1.VirtualTargetClass, + mergedParameters map[string]interface{}, + exp *jumpstarterdevv1alpha1.Exporter, +) error { + storageClass := virtualtargetv1alpha1.EffectiveStorageClassName(vtc, es) + if storageClass == "" { + return nil + } + + size, err := disk.SizeFromParameters(mergedParameters) + if err != nil { + return fmt.Errorf("disk size for %s: %w", exp.Name, err) + } + + labels := maps.Clone(es.Spec.Template.Metadata.Labels) + if labels == nil { + labels = make(map[string]string) + } + labels[labelExporterSetName] = es.Name + + desired := disk.BuildPVC(exp.Namespace, exp.Name, storageClass, size, labels) + + existing := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: desired.Name, + Namespace: desired.Namespace, + }, + } + + _, err = controllerutil.CreateOrUpdate(ctx, r.Client, existing, func() error { + if err := ctrl.SetControllerReference(exp, existing, r.Scheme); err != nil { + return fmt.Errorf("set owner on disk PVC for %s: %w", exp.Name, err) + } + // Spec is immutable after creation for most fields; only set on create. + if existing.CreationTimestamp.IsZero() { + existing.Labels = desired.Labels + existing.Spec = desired.Spec + } else if existing.Labels == nil { + existing.Labels = desired.Labels + } else { + existing.Labels[labelExporterSetName] = es.Name + } + return nil + }) + if err != nil { + return fmt.Errorf("ensure disk PVC for %s: %w", exp.Name, err) + } + return nil +} + // readCABundle fetches PEM CA data. If VTC specifies a CABundleConfigMapRef, // that is used. Otherwise falls back to the CertManager-generated ConfigMap. func (r *ExporterSetReconciler) readCABundle( diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py index f58c704bf..58999784e 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py @@ -285,13 +285,15 @@ async def on(self) -> None: # noqa: C901 for device in devices: cmdline += ["-device", device] - if bios.exists(): + if bios.exists() or self.parent._runtime_firmware_path(bios): cmdline += [ "-bios", str(bios), ] - if ovmf_code.exists() and ovmf_vars.exists(): + if (ovmf_code.exists() or self.parent._runtime_firmware_path(ovmf_code)) and ( + ovmf_vars.exists() or self.parent._runtime_firmware_path(ovmf_vars) + ): cmdline += [ "-drive", f"file={ovmf_code},if=pflash,format=raw,unit=0,readonly=on", @@ -358,7 +360,7 @@ async def on(self) -> None: # noqa: C901 cmdline += [ "-blockdev", - f"driver=vvfat,node-name=cidata,read-only=on,dir={self._cidata.name},label=CIDATA", + f"driver=vvfat,node-name=cidata,read-only=on,dir={self._cidata},label=CIDATA", "-device", "virtio-blk-pci,drive=cidata", ] @@ -482,10 +484,18 @@ def __post_init__(self): @property def _work_dir(self) -> str: + """Directory for sockets and jumpstarter-exec in sidecar mode.""" if self.launcher_socket: return "/shared" return self._tmp_dir.name + @property + def _disk_dir(self) -> str: + """Directory for flashable guest disk images (root, bios, …).""" + if self.launcher_socket: + return "/disk" + return self._tmp_dir.name + @property def _pty(self) -> str: return str(Path(self._work_dir) / "pty") @@ -515,6 +525,10 @@ def _wrap_command(self, cmd: list[str]) -> list[str]: def _cid(self) -> int: return randbits(32) + def _runtime_firmware_path(self, path: Path) -> bool: + """True when path is a default firmware path that lives in the runtime image.""" + return self.launcher_socket is not None and path in self.default_partitions.values() + def validate_partition( self, partition: str | None = None, @@ -522,13 +536,13 @@ def validate_partition( ) -> Path: match partition: case "root" | None: - path = Path(self._work_dir) / "root" + path = Path(self._disk_dir) / "root" case "OVMF_CODE.fd": - path = Path(self._work_dir) / "OVMF_CODE.fd" + path = Path(self._disk_dir) / "OVMF_CODE.fd" case "OVMF_VARS.fd": - path = Path(self._work_dir) / "OVMF_VARS.fd" + path = Path(self._disk_dir) / "OVMF_VARS.fd" case "bios": - path = Path(self._work_dir) / "bios" + path = Path(self._disk_dir) / "bios" case _: raise ValueError(f"invalid partition name: {partition}") @@ -537,10 +551,19 @@ def validate_partition( return path - def cidata(self) -> TemporaryDirectory: - tmp = TemporaryDirectory() + def cidata(self) -> Path: + """Write cloud-init cidata files; return the directory path. + + In sidecar mode the directory must be on the shared volume so the + runtime container can see it when QEMU is launched via jumpstarter-exec. + """ + if self.launcher_socket: + path = Path(self._work_dir) / "cidata" + path.mkdir(parents=True, exist_ok=True) + else: + self._cidata_tmp = TemporaryDirectory() + path = Path(self._cidata_tmp.name) - path = Path(tmp.name) (path / "meta-data").write_text( yaml.safe_dump( { @@ -566,7 +589,7 @@ def cidata(self) -> TemporaryDirectory: ) ) - return tmp + return path @export @validate_call(validate_return=True)