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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions controller/api/virtualtarget/v1alpha1/exporterset_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
30 changes: 30 additions & 0 deletions controller/api/virtualtarget/v1alpha1/storage.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
49 changes: 49 additions & 0 deletions controller/api/virtualtarget/v1alpha1/storage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") &&
Expand Down
6 changes: 6 additions & 0 deletions controller/hack/sample-x86_64-kind.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions controller/hack/sample-x86_64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions controller/internal/exporterset/disk/disk.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading