From 1295f8e79fab2e25b1846ea89551f70637baef23 Mon Sep 17 00:00:00 2001 From: Jared Bunting Date: Thu, 7 May 2026 23:35:41 -0400 Subject: [PATCH] feat: add support for generic ephemeral volumes Add a new feature flag `kubernetes.podspec-volumes-ephemeral` to allow generic ephemeral volumes (volumeClaimTemplate) in Knative services. This enables workloads that need per-pod dynamically provisioned storage, such as local NVMe scratch space on cloud VMs. Also exempt ephemeral volumes from the forced readOnly default, matching the existing behavior for emptyDir and PersistentVolumeClaim volumes. Co-Authored-By: Claude Opus 4.6 --- cmd/schema-tweak/overrides.go | 3 ++ config/core/300-resources/configuration.yaml | 5 +++ config/core/300-resources/revision.yaml | 5 +++ config/core/300-resources/service.yaml | 5 +++ config/core/configmaps/features.yaml | 8 +++- pkg/apis/config/features.go | 4 ++ pkg/apis/config/features_test.go | 18 ++++++++ pkg/apis/serving/fieldmask.go | 4 ++ pkg/apis/serving/k8s_validation.go | 13 +++++- pkg/apis/serving/k8s_validation_test.go | 46 ++++++++++++++++++++ pkg/apis/serving/v1/revision_defaults.go | 2 +- 11 files changed, 110 insertions(+), 3 deletions(-) diff --git a/cmd/schema-tweak/overrides.go b/cmd/schema-tweak/overrides.go index d08d86d9bc47..fc66e3a1cf61 100644 --- a/cmd/schema-tweak/overrides.go +++ b/cmd/schema-tweak/overrides.go @@ -254,6 +254,9 @@ func revSpecOverrides(prefixPath string) []entry { }, { name: "image", flag: config.FeaturePodSpecVolumesImage, + }, { + name: "ephemeral", + flag: config.FeaturePodSpecVolumesEphemeral, }}, }, { path: "volumes.secret", diff --git a/config/core/300-resources/configuration.yaml b/config/core/300-resources/configuration.yaml index 7184a28a704d..63627a6a3db6 100644 --- a/config/core/300-resources/configuration.yaml +++ b/config/core/300-resources/configuration.yaml @@ -1180,6 +1180,11 @@ spec: This is accessible behind a feature flag - kubernetes.podspec-volumes-emptydir type: object x-kubernetes-preserve-unknown-fields: true + ephemeral: + description: |- + This is accessible behind a feature flag - kubernetes.podspec-volumes-ephemeral + type: object + x-kubernetes-preserve-unknown-fields: true hostPath: description: |- This is accessible behind a feature flag - kubernetes.podspec-volumes-hostpath diff --git a/config/core/300-resources/revision.yaml b/config/core/300-resources/revision.yaml index 5da180f1bbe0..00f4b1d83c88 100644 --- a/config/core/300-resources/revision.yaml +++ b/config/core/300-resources/revision.yaml @@ -1156,6 +1156,11 @@ spec: This is accessible behind a feature flag - kubernetes.podspec-volumes-emptydir type: object x-kubernetes-preserve-unknown-fields: true + ephemeral: + description: |- + This is accessible behind a feature flag - kubernetes.podspec-volumes-ephemeral + type: object + x-kubernetes-preserve-unknown-fields: true hostPath: description: |- This is accessible behind a feature flag - kubernetes.podspec-volumes-hostpath diff --git a/config/core/300-resources/service.yaml b/config/core/300-resources/service.yaml index e2b14b4fbbed..a03abc596aad 100644 --- a/config/core/300-resources/service.yaml +++ b/config/core/300-resources/service.yaml @@ -1198,6 +1198,11 @@ spec: This is accessible behind a feature flag - kubernetes.podspec-volumes-emptydir type: object x-kubernetes-preserve-unknown-fields: true + ephemeral: + description: |- + This is accessible behind a feature flag - kubernetes.podspec-volumes-ephemeral + type: object + x-kubernetes-preserve-unknown-fields: true hostPath: description: |- This is accessible behind a feature flag - kubernetes.podspec-volumes-hostpath diff --git a/config/core/configmaps/features.yaml b/config/core/configmaps/features.yaml index 96fbc7844db4..638a934e3dc4 100644 --- a/config/core/configmaps/features.yaml +++ b/config/core/configmaps/features.yaml @@ -22,7 +22,7 @@ metadata: app.kubernetes.io/component: controller app.kubernetes.io/version: devel annotations: - knative.dev/example-checksum: "bee75b26" + knative.dev/example-checksum: "424df6ce" data: _example: |- ################################ @@ -207,6 +207,12 @@ data: # 2. Disabled: disabling HostPath volume support kubernetes.podspec-volumes-hostpath: "disabled" + # Controls whether volume support for generic ephemeral volumes is enabled or not. + # See https://kubernetes.io/docs/concepts/storage/ephemeral-volumes/#generic-ephemeral-volumes + # 1. Enabled: enabling generic ephemeral volume support + # 2. Disabled: disabling generic ephemeral volume support + kubernetes.podspec-volumes-ephemeral: "disabled" + # Controls whether volume support for CSI is enabled or not. # 1. Enabled: enabling CSI volume support # 2. Disabled: disabling CSI volume support diff --git a/pkg/apis/config/features.go b/pkg/apis/config/features.go index ed5eb6ea844d..74ce0e7723c4 100644 --- a/pkg/apis/config/features.go +++ b/pkg/apis/config/features.go @@ -76,6 +76,7 @@ const ( FeaturePodSpecShareProcessNamespace = "kubernetes.podspec-shareprocessnamespace" FeaturePodSpecTolerations = "kubernetes.podspec-tolerations" FeaturePodSpecTopologySpreadConstraints = "kubernetes.podspec-topologyspreadconstraints" + FeaturePodSpecVolumesEphemeral = "kubernetes.podspec-volumes-ephemeral" FeaturePodSpecVolumesImage = "kubernetes.podspec-volumes-image" ) @@ -102,6 +103,7 @@ func defaultFeaturesConfig() *Features { PodSpecVolumesHostPath: Disabled, PodSpecVolumesMountPropagation: Disabled, PodSpecVolumesCSI: Disabled, + PodSpecVolumesEphemeral: Disabled, PodSpecVolumesImage: Disabled, PodSpecPersistentVolumeClaim: Disabled, PodSpecPersistentVolumeWrite: Disabled, @@ -141,6 +143,7 @@ func NewFeaturesConfigFromMap(data map[string]string) (*Features, error) { asFlag(FeaturePodSpecHostPID, &nc.PodSpecHostPID), asFlag(FeaturePodSpecHostPath, &nc.PodSpecVolumesHostPath), asFlag(FeaturePodSpecVolumesCSI, &nc.PodSpecVolumesCSI), + asFlag(FeaturePodSpecVolumesEphemeral, &nc.PodSpecVolumesEphemeral), asFlag(FeaturePodSpecVolumesImage, &nc.PodSpecVolumesImage), asFlag(FeaturePodSpecInitContainers, &nc.PodSpecInitContainers), asFlag(FeaturePodSpecVolumesMountPropagation, &nc.PodSpecVolumesMountPropagation), @@ -187,6 +190,7 @@ type Features struct { PodSpecVolumesHostPath Flag PodSpecVolumesMountPropagation Flag PodSpecVolumesCSI Flag + PodSpecVolumesEphemeral Flag PodSpecVolumesImage Flag PodSpecInitContainers Flag PodSpecPersistentVolumeClaim Flag diff --git a/pkg/apis/config/features_test.go b/pkg/apis/config/features_test.go index 49135294b19a..f1aef4566a8d 100644 --- a/pkg/apis/config/features_test.go +++ b/pkg/apis/config/features_test.go @@ -480,6 +480,24 @@ func TestFeaturesConfiguration(t *testing.T) { data: map[string]string{ "kubernetes.podspec-volumes-csi": "Enabled", }, + }, { + name: "kubernetes.podspec-volumes-ephemeral Disabled", + wantErr: false, + wantFeatures: defaultWith(&Features{ + PodSpecVolumesEphemeral: Disabled, + }), + data: map[string]string{ + "kubernetes.podspec-volumes-ephemeral": "Disabled", + }, + }, { + name: "kubernetes.podspec-volumes-ephemeral Enabled", + wantErr: false, + wantFeatures: defaultWith(&Features{ + PodSpecVolumesEphemeral: Enabled, + }), + data: map[string]string{ + "kubernetes.podspec-volumes-ephemeral": "Enabled", + }, }, { name: "kubernetes.podspec-persistent-volume-claim Disabled", wantErr: false, diff --git a/pkg/apis/serving/fieldmask.go b/pkg/apis/serving/fieldmask.go index 73c48b1b5f64..43affd8eaeb9 100644 --- a/pkg/apis/serving/fieldmask.go +++ b/pkg/apis/serving/fieldmask.go @@ -76,6 +76,10 @@ func VolumeSourceMask(ctx context.Context, in *corev1.VolumeSource) *corev1.Volu out.CSI = in.CSI } + if cfg.Features.PodSpecVolumesEphemeral != config.Disabled { + out.Ephemeral = in.Ephemeral + } + if cfg.Features.PodSpecVolumesImage != config.Disabled { out.Image = in.Image } diff --git a/pkg/apis/serving/k8s_validation.go b/pkg/apis/serving/k8s_validation.go index fd90e0f1583f..d02ff0211e83 100644 --- a/pkg/apis/serving/k8s_validation.go +++ b/pkg/apis/serving/k8s_validation.go @@ -138,6 +138,10 @@ func validateVolume(ctx context.Context, volume corev1.Volume) *apis.FieldError errs = errs.Also(&apis.FieldError{Message: fmt.Sprintf("CSI volume support is disabled, "+ "but found CSI volume %s", volume.Name)}) } + if volume.Ephemeral != nil && features.PodSpecVolumesEphemeral != config.Enabled { + errs = errs.Also(&apis.FieldError{Message: fmt.Sprintf("Ephemeral volume support is disabled, "+ + "but found Ephemeral volume %s", volume.Name)}) + } errs = errs.Also(apis.CheckDisallowedFields(volume, *VolumeMask(ctx, &volume))) if volume.Name == "" { errs = apis.ErrMissingField("name") @@ -182,6 +186,10 @@ func validateVolume(ctx context.Context, volume corev1.Volume) *apis.FieldError specified = append(specified, "csi") } + if vs.Ephemeral != nil { + specified = append(specified, "ephemeral") + } + if vs.Image != nil { specified = append(specified, "image") errs = errs.Also(validateImageVolumeSource(vs.Image).ViaField("image")) @@ -202,6 +210,9 @@ func validateVolume(ctx context.Context, volume corev1.Volume) *apis.FieldError if cfg.Features.PodSpecVolumesCSI == config.Enabled { fieldPaths = append(fieldPaths, "csi") } + if cfg.Features.PodSpecVolumesEphemeral == config.Enabled { + fieldPaths = append(fieldPaths, "ephemeral") + } if cfg.Features.PodSpecVolumesImage == config.Enabled { fieldPaths = append(fieldPaths, "image") } @@ -717,7 +728,7 @@ func validateVolumeMounts(ctx context.Context, mounts []corev1.VolumeMount, volu } seenMountPath.Insert(path.Clean(vm.MountPath)) - shouldCheckReadOnlyVolume := volumes[vm.Name].EmptyDir == nil && volumes[vm.Name].PersistentVolumeClaim == nil + shouldCheckReadOnlyVolume := volumes[vm.Name].EmptyDir == nil && volumes[vm.Name].PersistentVolumeClaim == nil && volumes[vm.Name].Ephemeral == nil if shouldCheckReadOnlyVolume && !vm.ReadOnly { errs = errs.Also((&apis.FieldError{ Message: "volume mount should be readOnly for this type of volume", diff --git a/pkg/apis/serving/k8s_validation_test.go b/pkg/apis/serving/k8s_validation_test.go index 711f7a565dea..fa6a62508458 100644 --- a/pkg/apis/serving/k8s_validation_test.go +++ b/pkg/apis/serving/k8s_validation_test.go @@ -157,6 +157,13 @@ func withPodSpecVolumesCSIEnabled() configOption { } } +func withPodSpecVolumesEphemeralEnabled() configOption { + return func(cfg *config.Config) *config.Config { + cfg.Features.PodSpecVolumesEphemeral = config.Enabled + return cfg + } +} + func withPodSpecVolumesImageEnabled() configOption { return func(cfg *config.Config) *config.Config { cfg.Features.PodSpecVolumesImage = config.Enabled @@ -3492,6 +3499,45 @@ func TestVolumeValidation(t *testing.T) { }, cfgOpts: []configOption{withPodSpecVolumesImageEnabled()}, want: apis.ErrMissingOneOf("secret", "configMap", "projected", "emptyDir", "image"), + }, { + name: "valid ephemeral volume with feature enabled", + v: corev1.Volume{ + Name: "foo", + VolumeSource: corev1.VolumeSource{ + Ephemeral: &corev1.EphemeralVolumeSource{ + VolumeClaimTemplate: &corev1.PersistentVolumeClaimTemplate{ + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + }, + }, + }, + cfgOpts: []configOption{withPodSpecVolumesEphemeralEnabled()}, + }, { + name: "ephemeral volume with feature disabled", + v: corev1.Volume{ + Name: "foo", + VolumeSource: corev1.VolumeSource{ + Ephemeral: &corev1.EphemeralVolumeSource{ + VolumeClaimTemplate: &corev1.PersistentVolumeClaimTemplate{ + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + }, + }, + }, + want: (&apis.FieldError{ + Message: `Ephemeral volume support is disabled, but found Ephemeral volume foo`, + }).Also(&apis.FieldError{Message: "must not set the field(s)", Paths: []string{"ephemeral"}}), + }, { + name: "missing ephemeral volume when required", + v: corev1.Volume{ + Name: "foo", + }, + cfgOpts: []configOption{withPodSpecVolumesEphemeralEnabled()}, + want: apis.ErrMissingOneOf("secret", "configMap", "projected", "emptyDir", "ephemeral"), }} for _, test := range tests { diff --git a/pkg/apis/serving/v1/revision_defaults.go b/pkg/apis/serving/v1/revision_defaults.go index be8c81238032..f2014724bf45 100644 --- a/pkg/apis/serving/v1/revision_defaults.go +++ b/pkg/apis/serving/v1/revision_defaults.go @@ -145,7 +145,7 @@ func (rs *RevisionSpec) applyDefault(ctx context.Context, container *corev1.Cont vNames := make(sets.Set[string]) for _, v := range rs.PodSpec.Volumes { - if v.EmptyDir != nil || v.PersistentVolumeClaim != nil { + if v.EmptyDir != nil || v.PersistentVolumeClaim != nil || v.Ephemeral != nil { vNames.Insert(v.Name) } }