diff --git a/pkg/api/pod/util.go b/pkg/api/pod/util.go index 61d74bf177c..c99a8e34f30 100644 --- a/pkg/api/pod/util.go +++ b/pkg/api/pod/util.go @@ -550,6 +550,42 @@ func dropDisabledFields( dropDisabledTopologySpreadConstraintsFields(podSpec, oldPodSpec) dropDisabledNodeInclusionPolicyFields(podSpec, oldPodSpec) dropDisabledMatchLabelKeysField(podSpec, oldPodSpec) + dropDisabledDynamicResourceAllocationFields(podSpec, oldPodSpec) +} + +// dropDisabledDynamicResourceAllocationFields removes pod claim references from +// container specs and pod-level resource claims unless they are already used +// by the old pod spec. +func dropDisabledDynamicResourceAllocationFields(podSpec, oldPodSpec *api.PodSpec) { + if !utilfeature.DefaultFeatureGate.Enabled(features.DynamicResourceAllocation) && !dynamicResourceAllocationInUse(oldPodSpec) { + dropResourceClaimRequests(podSpec.Containers) + dropResourceClaimRequests(podSpec.InitContainers) + dropEphemeralResourceClaimRequests(podSpec.EphemeralContainers) + podSpec.ResourceClaims = nil + } +} + +func dynamicResourceAllocationInUse(podSpec *api.PodSpec) bool { + if podSpec == nil { + return false + } + + // We only need to check this field because the containers cannot have + // resource requirements entries for claims without a corresponding + // entry at the pod spec level. + return len(podSpec.ResourceClaims) > 0 +} + +func dropResourceClaimRequests(containers []api.Container) { + for i := range containers { + containers[i].Resources.Claims = nil + } +} + +func dropEphemeralResourceClaimRequests(containers []api.EphemeralContainer) { + for i := range containers { + containers[i].Resources.Claims = nil + } } // dropDisabledTopologySpreadConstraintsFields removes disabled fields from PodSpec related diff --git a/pkg/api/pod/util_test.go b/pkg/api/pod/util_test.go index 1dfef8aa245..0271e1a5271 100644 --- a/pkg/api/pod/util_test.go +++ b/pkg/api/pod/util_test.go @@ -784,6 +784,165 @@ func TestDropAppArmor(t *testing.T) { } } +func TestDropDynamicResourceAllocation(t *testing.T) { + resourceClaimName := "external-claim" + + podWithClaims := &api.Pod{ + Spec: api.PodSpec{ + Containers: []api.Container{ + { + Resources: api.ResourceRequirements{ + Claims: []api.ResourceClaim{{Name: "my-claim"}}, + }, + }, + }, + InitContainers: []api.Container{ + { + Resources: api.ResourceRequirements{ + Claims: []api.ResourceClaim{{Name: "my-claim"}}, + }, + }, + }, + EphemeralContainers: []api.EphemeralContainer{ + { + EphemeralContainerCommon: api.EphemeralContainerCommon{ + Resources: api.ResourceRequirements{ + Claims: []api.ResourceClaim{{Name: "my-claim"}}, + }, + }, + }, + }, + ResourceClaims: []api.PodResourceClaim{ + { + Name: "my-claim", + Source: api.ClaimSource{ + ResourceClaimName: &resourceClaimName, + }, + }, + }, + }, + } + podWithoutClaims := &api.Pod{ + Spec: api.PodSpec{ + Containers: []api.Container{{}}, + InitContainers: []api.Container{{}}, + EphemeralContainers: []api.EphemeralContainer{{}}, + }, + } + + var noPod *api.Pod + + testcases := []struct { + description string + enabled bool + oldPod *api.Pod + newPod *api.Pod + wantPod *api.Pod + }{ + { + description: "old with claims / new with claims / disabled", + oldPod: podWithClaims, + newPod: podWithClaims, + wantPod: podWithClaims, + }, + { + description: "old without claims / new with claims / disabled", + oldPod: podWithoutClaims, + newPod: podWithClaims, + wantPod: podWithoutClaims, + }, + { + description: "no old pod/ new with claims / disabled", + oldPod: noPod, + newPod: podWithClaims, + wantPod: podWithoutClaims, + }, + + { + description: "old with claims / new without claims / disabled", + oldPod: podWithClaims, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + { + description: "old without claims / new without claims / disabled", + oldPod: podWithoutClaims, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + { + description: "no old pod/ new without claims / disabled", + oldPod: noPod, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + + { + description: "old with claims / new with claims / enabled", + enabled: true, + oldPod: podWithClaims, + newPod: podWithClaims, + wantPod: podWithClaims, + }, + { + description: "old without claims / new with claims / enabled", + enabled: true, + oldPod: podWithoutClaims, + newPod: podWithClaims, + wantPod: podWithClaims, + }, + { + description: "no old pod/ new with claims / enabled", + enabled: true, + oldPod: noPod, + newPod: podWithClaims, + wantPod: podWithClaims, + }, + + { + description: "old with claims / new without claims / enabled", + enabled: true, + oldPod: podWithClaims, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + { + description: "old without claims / new without claims / enabled", + enabled: true, + oldPod: podWithoutClaims, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + { + description: "no old pod/ new without claims / enabled", + enabled: true, + oldPod: noPod, + newPod: podWithoutClaims, + wantPod: podWithoutClaims, + }, + } + + for _, tc := range testcases { + t.Run(tc.description, func(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DynamicResourceAllocation, tc.enabled)() + + oldPod := tc.oldPod.DeepCopy() + newPod := tc.newPod.DeepCopy() + wantPod := tc.wantPod + DropDisabledPodFields(newPod, oldPod) + + // old pod should never be changed + if diff := cmp.Diff(oldPod, tc.oldPod); diff != "" { + t.Errorf("old pod changed: %s", diff) + } + + if diff := cmp.Diff(wantPod, newPod); diff != "" { + t.Errorf("new pod changed (- want, + got): %s", diff) + } + }) + } +} + func TestDropProbeGracePeriod(t *testing.T) { podWithProbeGracePeriod := func() *api.Pod { livenessGracePeriod := int64(10) diff --git a/pkg/apis/core/types.go b/pkg/apis/core/types.go index 0622050a17d..fbbecb00d58 100644 --- a/pkg/apis/core/types.go +++ b/pkg/apis/core/types.go @@ -2185,6 +2185,25 @@ type ResourceRequirements struct { // otherwise to an implementation-defined value // +optional Requests ResourceList + // Claims lists the names of resources, defined in spec.resourceClaims, + // that are used by this container. + // + // This is an alpha field and requires enabling the + // DynamicResourceAllocation feature gate. + // + // This field is immutable. + // + // +featureGate=DynamicResourceAllocation + // +optional + Claims []ResourceClaim +} + +// ResourceClaim references one entry in PodSpec.ResourceClaims. +type ResourceClaim struct { + // Name must match the name of one entry in pod.spec.resourceClaims of + // the Pod where this field is used. It makes that resource available + // inside a container. + Name string } // Container represents a single container that is expected to be run on the host. @@ -3024,12 +3043,68 @@ type PodSpec struct { // - spec.containers[*].securityContext.runAsGroup // +optional OS *PodOS + // SchedulingGates is an opaque list of values that if specified will block scheduling the pod. // More info: https://git.k8s.io/enhancements/keps/sig-scheduling/3521-pod-scheduling-readiness. // // This is an alpha-level feature enabled by PodSchedulingReadiness feature gate. // +optional SchedulingGates []PodSchedulingGate + // ResourceClaims defines which ResourceClaims must be allocated + // and reserved before the Pod is allowed to start. The resources + // will be made available to those containers which consume them + // by name. + // + // This is an alpha field and requires enabling the + // DynamicResourceAllocation feature gate. + // + // This field is immutable. + // + // +featureGate=DynamicResourceAllocation + // +optional + ResourceClaims []PodResourceClaim +} + +// PodResourceClaim references exactly one ResourceClaim through a ClaimSource. +// It adds a name to it that uniquely identifies the ResourceClaim inside the Pod. +// Containers that need access to the ResourceClaim reference it with this name. +type PodResourceClaim struct { + // Name uniquely identifies this resource claim inside the pod. + // This must be a DNS_LABEL. + Name string + + // Source describes where to find the ResourceClaim. + Source ClaimSource +} + +// ClaimSource describes a reference to a ResourceClaim. +// +// Exactly one of these fields should be set. Consumers of this type must +// treat an empty object as if it has an unknown value. +type ClaimSource struct { + // ResourceClaimName is the name of a ResourceClaim object in the same + // namespace as this pod. + ResourceClaimName *string + + // ResourceClaimTemplateName is the name of a ResourceClaimTemplate + // object in the same namespace as this pod. + // + // The template will be used to create a new ResourceClaim, which will + // be bound to this pod. When this pod is deleted, the ResourceClaim + // will also be deleted. The name of the ResourceClaim will be -, where is the + // PodResourceClaim.Name. Pod validation will reject the pod if the + // concatenated name is not valid for a ResourceClaim (e.g. too long). + // + // An existing ResourceClaim with that name that is not owned by the + // pod will not be used for the pod to avoid using an unrelated + // resource by mistake. Scheduling and pod startup are then blocked + // until the unrelated ResourceClaim is removed. + // + // This field is immutable and no changes will be made to the + // corresponding ResourceClaim by the control plane after creating the + // ResourceClaim. + ResourceClaimTemplateName *string } // OSName is the set of OS'es that can be used in OS. diff --git a/pkg/apis/core/validation/validation.go b/pkg/apis/core/validation/validation.go index 8c5611cec59..d53339863b3 100644 --- a/pkg/apis/core/validation/validation.go +++ b/pkg/apis/core/validation/validation.go @@ -309,7 +309,7 @@ func ValidateRuntimeClassName(name string, fldPath *field.Path) field.ErrorList // validateOverhead can be used to check whether the given Overhead is valid. func validateOverhead(overhead core.ResourceList, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { // reuse the ResourceRequirements validation logic - return ValidateResourceRequirements(&core.ResourceRequirements{Limits: overhead}, fldPath, opts) + return ValidateResourceRequirements(&core.ResourceRequirements{Limits: overhead}, nil, fldPath, opts) } // Validates that given value is not negative. @@ -1621,12 +1621,12 @@ func validateEphemeralVolumeSource(ephemeral *core.EphemeralVolumeSource, fldPat // ValidatePersistentVolumeClaimTemplate verifies that the embedded object meta and spec are valid. // Checking of the object data is very minimal because only labels and annotations are used. func ValidatePersistentVolumeClaimTemplate(claimTemplate *core.PersistentVolumeClaimTemplate, fldPath *field.Path, opts PersistentVolumeClaimSpecValidationOptions) field.ErrorList { - allErrs := validatePersistentVolumeClaimTemplateObjectMeta(&claimTemplate.ObjectMeta, fldPath.Child("metadata")) + allErrs := ValidateTemplateObjectMeta(&claimTemplate.ObjectMeta, fldPath.Child("metadata")) allErrs = append(allErrs, ValidatePersistentVolumeClaimSpec(&claimTemplate.Spec, fldPath.Child("spec"), opts)...) return allErrs } -func validatePersistentVolumeClaimTemplateObjectMeta(objMeta *metav1.ObjectMeta, fldPath *field.Path) field.ErrorList { +func ValidateTemplateObjectMeta(objMeta *metav1.ObjectMeta, fldPath *field.Path) field.ErrorList { allErrs := apimachineryvalidation.ValidateAnnotations(objMeta.Annotations, fldPath.Child("annotations")) allErrs = append(allErrs, unversionedvalidation.ValidateLabels(objMeta.Labels, fldPath.Child("labels"))...) // All other fields are not supported and thus must not be set @@ -1634,11 +1634,11 @@ func validatePersistentVolumeClaimTemplateObjectMeta(objMeta *metav1.ObjectMeta, // but then adding a new one to ObjectMeta wouldn't be checked // unless this code gets updated. Instead, we ensure that // only allowed fields are set via reflection. - allErrs = append(allErrs, validateFieldAllowList(*objMeta, allowedPVCTemplateObjectMetaFields, "cannot be set for an ephemeral volume", fldPath)...) + allErrs = append(allErrs, validateFieldAllowList(*objMeta, allowedTemplateObjectMetaFields, "cannot be set", fldPath)...) return allErrs } -var allowedPVCTemplateObjectMetaFields = map[string]bool{ +var allowedTemplateObjectMetaFields = map[string]bool{ "Annotations": true, "Labels": true, } @@ -2768,6 +2768,54 @@ func ValidateVolumeDevices(devices []core.VolumeDevice, volmounts map[string]str return allErrs } +func validatePodResourceClaims(claims []core.PodResourceClaim, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + podClaimNames := sets.NewString() + for i, claim := range claims { + allErrs = append(allErrs, validatePodResourceClaim(claim, &podClaimNames, fldPath.Index(i))...) + } + return allErrs +} + +// gatherPodResourceClaimNames returns a set of all non-empty +// PodResourceClaim.Name values. Validation that those names are valid is +// handled by validatePodResourceClaims. +func gatherPodResourceClaimNames(claims []core.PodResourceClaim) sets.String { + podClaimNames := sets.String{} + for _, claim := range claims { + if claim.Name != "" { + podClaimNames.Insert(claim.Name) + } + } + return podClaimNames +} + +func validatePodResourceClaim(claim core.PodResourceClaim, podClaimNames *sets.String, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if claim.Name == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("name"), "")) + } else if podClaimNames.Has(claim.Name) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("name"), claim.Name)) + } else { + allErrs = append(allErrs, ValidateDNS1123Label(claim.Name, fldPath.Child("name"))...) + podClaimNames.Insert(claim.Name) + } + allErrs = append(allErrs, validatePodResourceClaimSource(claim.Source, fldPath.Child("source"))...) + + return allErrs +} + +func validatePodResourceClaimSource(claimSource core.ClaimSource, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if claimSource.ResourceClaimName != nil && claimSource.ResourceClaimTemplateName != nil { + allErrs = append(allErrs, field.Invalid(fldPath, claimSource, "at most one of `resourceClaimName` or `resourceClaimTemplateName` may be specified")) + } + if claimSource.ResourceClaimName == nil && claimSource.ResourceClaimTemplateName == nil { + allErrs = append(allErrs, field.Invalid(fldPath, claimSource, "must specify one of: `resourceClaimName`, `resourceClaimTemplateName`")) + } + return allErrs +} + func validateProbe(probe *core.Probe, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} @@ -2990,8 +3038,8 @@ func validatePullPolicy(policy core.PullPolicy, fldPath *field.Path) field.Error // validateEphemeralContainers is called by pod spec and template validation to validate the list of ephemeral containers. // Note that this is called for pod template even though ephemeral containers aren't allowed in pod templates. -func validateEphemeralContainers(ephemeralContainers []core.EphemeralContainer, containers, initContainers []core.Container, volumes map[string]core.VolumeSource, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { - allErrs := field.ErrorList{} +func validateEphemeralContainers(ephemeralContainers []core.EphemeralContainer, containers, initContainers []core.Container, volumes map[string]core.VolumeSource, podClaimNames sets.String, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { + var allErrs field.ErrorList if len(ephemeralContainers) == 0 { return allErrs @@ -3011,7 +3059,7 @@ func validateEphemeralContainers(ephemeralContainers []core.EphemeralContainer, idxPath := fldPath.Index(i) c := (*core.Container)(&ec.EphemeralContainerCommon) - allErrs = append(allErrs, validateContainerCommon(c, volumes, idxPath, opts)...) + allErrs = append(allErrs, validateContainerCommon(c, volumes, podClaimNames, idxPath, opts)...) // Ephemeral containers don't need looser constraints for pod templates, so it's convenient to apply both validations // here where we've already converted EphemeralContainerCommon to Container. allErrs = append(allErrs, validateContainerOnlyForPod(c, idxPath)...) @@ -3049,7 +3097,7 @@ func validateEphemeralContainers(ephemeralContainers []core.EphemeralContainer, return allErrs } -// validateFieldAcceptList checks that only allowed fields are set. +// ValidateFieldAcceptList checks that only allowed fields are set. // The value must be a struct (not a pointer to a struct!). func validateFieldAllowList(value interface{}, allowedFields map[string]bool, errorText string, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList @@ -3073,7 +3121,7 @@ func validateFieldAllowList(value interface{}, allowedFields map[string]bool, er } // validateInitContainers is called by pod spec and template validation to validate the list of init containers -func validateInitContainers(containers []core.Container, regularContainers []core.Container, volumes map[string]core.VolumeSource, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { +func validateInitContainers(containers []core.Container, regularContainers []core.Container, volumes map[string]core.VolumeSource, podClaimNames sets.String, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { var allErrs field.ErrorList allNames := sets.String{} @@ -3084,7 +3132,7 @@ func validateInitContainers(containers []core.Container, regularContainers []cor idxPath := fldPath.Index(i) // Apply the validation common to all container types - allErrs = append(allErrs, validateContainerCommon(&ctr, volumes, idxPath, opts)...) + allErrs = append(allErrs, validateContainerCommon(&ctr, volumes, podClaimNames, idxPath, opts)...) // Names must be unique within regular and init containers. Collisions with ephemeral containers // will be detected by validateEphemeralContainers(). @@ -3117,8 +3165,8 @@ func validateInitContainers(containers []core.Container, regularContainers []cor // validateContainerCommon applies validation common to all container types. It's called by regular, init, and ephemeral // container list validation to require a properly formatted name, image, etc. -func validateContainerCommon(ctr *core.Container, volumes map[string]core.VolumeSource, path *field.Path, opts PodValidationOptions) field.ErrorList { - allErrs := field.ErrorList{} +func validateContainerCommon(ctr *core.Container, volumes map[string]core.VolumeSource, podClaimNames sets.String, path *field.Path, opts PodValidationOptions) field.ErrorList { + var allErrs field.ErrorList namePath := path.Child("name") if len(ctr.Name) == 0 { @@ -3154,7 +3202,7 @@ func validateContainerCommon(ctr *core.Container, volumes map[string]core.Volume allErrs = append(allErrs, ValidateVolumeMounts(ctr.VolumeMounts, volDevices, volumes, ctr, path.Child("volumeMounts"))...) allErrs = append(allErrs, ValidateVolumeDevices(ctr.VolumeDevices, volMounts, volumes, path.Child("volumeDevices"))...) allErrs = append(allErrs, validatePullPolicy(ctr.ImagePullPolicy, path.Child("imagePullPolicy"))...) - allErrs = append(allErrs, ValidateResourceRequirements(&ctr.Resources, path.Child("resources"), opts)...) + allErrs = append(allErrs, ValidateResourceRequirements(&ctr.Resources, podClaimNames, path.Child("resources"), opts)...) allErrs = append(allErrs, ValidateSecurityContext(ctr.SecurityContext, path.Child("securityContext"))...) return allErrs } @@ -3207,7 +3255,7 @@ func validateHostUsers(spec *core.PodSpec, fldPath *field.Path) field.ErrorList } // validateContainers is called by pod spec and template validation to validate the list of regular containers. -func validateContainers(containers []core.Container, volumes map[string]core.VolumeSource, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { +func validateContainers(containers []core.Container, volumes map[string]core.VolumeSource, podClaimNames sets.String, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { allErrs := field.ErrorList{} if len(containers) == 0 { @@ -3219,7 +3267,7 @@ func validateContainers(containers []core.Container, volumes map[string]core.Vol path := fldPath.Index(i) // Apply validation common to all containers - allErrs = append(allErrs, validateContainerCommon(&ctr, volumes, path, opts)...) + allErrs = append(allErrs, validateContainerCommon(&ctr, volumes, podClaimNames, path, opts)...) // Container names must be unique within the list of regular containers. // Collisions with init or ephemeral container names will be detected by the init or ephemeral @@ -3697,9 +3745,11 @@ func ValidatePodSpec(spec *core.PodSpec, podMeta *metav1.ObjectMeta, fldPath *fi vols, vErrs := ValidateVolumes(spec.Volumes, podMeta, fldPath.Child("volumes"), opts) allErrs = append(allErrs, vErrs...) - allErrs = append(allErrs, validateContainers(spec.Containers, vols, fldPath.Child("containers"), opts)...) - allErrs = append(allErrs, validateInitContainers(spec.InitContainers, spec.Containers, vols, fldPath.Child("initContainers"), opts)...) - allErrs = append(allErrs, validateEphemeralContainers(spec.EphemeralContainers, spec.Containers, spec.InitContainers, vols, fldPath.Child("ephemeralContainers"), opts)...) + podClaimNames := gatherPodResourceClaimNames(spec.ResourceClaims) + allErrs = append(allErrs, validatePodResourceClaims(spec.ResourceClaims, fldPath.Child("resourceClaims"))...) + allErrs = append(allErrs, validateContainers(spec.Containers, vols, podClaimNames, fldPath.Child("containers"), opts)...) + allErrs = append(allErrs, validateInitContainers(spec.InitContainers, spec.Containers, vols, podClaimNames, fldPath.Child("initContainers"), opts)...) + allErrs = append(allErrs, validateEphemeralContainers(spec.EphemeralContainers, spec.Containers, spec.InitContainers, vols, podClaimNames, fldPath.Child("ephemeralContainers"), opts)...) allErrs = append(allErrs, validateRestartPolicy(&spec.RestartPolicy, fldPath.Child("restartPolicy"))...) allErrs = append(allErrs, validateDNSPolicy(&spec.DNSPolicy, fldPath.Child("dnsPolicy"))...) allErrs = append(allErrs, unversionedvalidation.ValidateLabels(spec.NodeSelector, fldPath.Child("nodeSelector"))...) @@ -5856,7 +5906,7 @@ func validateBasicResource(quantity resource.Quantity, fldPath *field.Path) fiel } // Validates resource requirement spec. -func ValidateResourceRequirements(requirements *core.ResourceRequirements, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { +func ValidateResourceRequirements(requirements *core.ResourceRequirements, podClaimNames sets.String, fldPath *field.Path, opts PodValidationOptions) field.ErrorList { allErrs := field.ErrorList{} limPath := fldPath.Child("limits") reqPath := fldPath.Child("requests") @@ -5919,6 +5969,42 @@ func ValidateResourceRequirements(requirements *core.ResourceRequirements, fldPa allErrs = append(allErrs, field.Forbidden(fldPath, "HugePages require cpu or memory")) } + allErrs = append(allErrs, validateResourceClaimNames(requirements.Claims, podClaimNames, fldPath.Child("claims"))...) + + return allErrs +} + +// validateResourceClaimNames checks that the names in +// ResourceRequirements.Claims have a corresponding entry in +// PodSpec.ResourceClaims. +func validateResourceClaimNames(claims []core.ResourceClaim, podClaimNames sets.String, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + names := sets.String{} + for i, claim := range claims { + name := claim.Name + if name == "" { + allErrs = append(allErrs, field.Required(fldPath.Index(i), "")) + } else { + if names.Has(name) { + allErrs = append(allErrs, field.Duplicate(fldPath.Index(i), name)) + } else { + names.Insert(name) + } + if !podClaimNames.Has(name) { + // field.NotFound doesn't accept an + // explanation. Adding one here is more + // user-friendly. + error := field.NotFound(fldPath.Index(i), name) + error.Detail = "must be one of the names in pod.spec.resourceClaims" + if len(podClaimNames) == 0 { + error.Detail += " which is empty" + } else { + error.Detail += ": " + strings.Join(podClaimNames.List(), ", ") + } + allErrs = append(allErrs, error) + } + } + } return allErrs } diff --git a/pkg/apis/core/validation/validation_test.go b/pkg/apis/core/validation/validation_test.go index 9947b30045b..5d7f4aee27d 100644 --- a/pkg/apis/core/validation/validation_test.go +++ b/pkg/apis/core/validation/validation_test.go @@ -5371,7 +5371,7 @@ func TestAlphaLocalStorageCapacityIsolation(t *testing.T) { resource.BinarySI), }, } - if errs := ValidateResourceRequirements(&containerLimitCase, field.NewPath("resources"), PodValidationOptions{}); len(errs) != 0 { + if errs := ValidateResourceRequirements(&containerLimitCase, nil, field.NewPath("resources"), PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success: %v", errs) } } @@ -6841,7 +6841,7 @@ func TestValidateEphemeralContainers(t *testing.T) { }, }, } { - if errs := validateEphemeralContainers(ephemeralContainers, containers, initContainers, vols, field.NewPath("ephemeralContainers"), PodValidationOptions{}); len(errs) != 0 { + if errs := validateEphemeralContainers(ephemeralContainers, containers, initContainers, vols, nil, field.NewPath("ephemeralContainers"), PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success for '%s' but got errors: %v", title, errs) } } @@ -7123,7 +7123,7 @@ func TestValidateEphemeralContainers(t *testing.T) { for _, tc := range tcs { t.Run(tc.title+"__@L"+tc.line, func(t *testing.T) { - errs := validateEphemeralContainers(tc.ephemeralContainers, containers, initContainers, vols, field.NewPath("ephemeralContainers"), PodValidationOptions{}) + errs := validateEphemeralContainers(tc.ephemeralContainers, containers, initContainers, vols, nil, field.NewPath("ephemeralContainers"), PodValidationOptions{}) if len(errs) == 0 { t.Fatal("expected error but received none") } @@ -7402,7 +7402,7 @@ func TestValidateContainers(t *testing.T) { TerminationMessagePolicy: "File", }, } - if errs := validateContainers(successCase, volumeDevices, field.NewPath("field"), PodValidationOptions{}); len(errs) != 0 { + if errs := validateContainers(successCase, volumeDevices, nil, field.NewPath("field"), PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success: %v", errs) } @@ -8026,7 +8026,7 @@ func TestValidateContainers(t *testing.T) { } for _, tc := range errorCases { t.Run(tc.title+"__@L"+tc.line, func(t *testing.T) { - errs := validateContainers(tc.containers, volumeDevices, field.NewPath("containers"), PodValidationOptions{}) + errs := validateContainers(tc.containers, volumeDevices, nil, field.NewPath("containers"), PodValidationOptions{}) if len(errs) == 0 { t.Fatal("expected error but received none") } @@ -8076,7 +8076,7 @@ func TestValidateInitContainers(t *testing.T) { TerminationMessagePolicy: "File", }, } - if errs := validateInitContainers(successCase, containers, volumeDevices, field.NewPath("field"), PodValidationOptions{}); len(errs) != 0 { + if errs := validateInitContainers(successCase, containers, volumeDevices, nil, field.NewPath("field"), PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success: %v", errs) } @@ -8268,7 +8268,7 @@ func TestValidateInitContainers(t *testing.T) { } for _, tc := range errorCases { t.Run(tc.title+"__@L"+tc.line, func(t *testing.T) { - errs := validateInitContainers(tc.initContainers, containers, volumeDevices, field.NewPath("initContainers"), PodValidationOptions{}) + errs := validateInitContainers(tc.initContainers, containers, volumeDevices, nil, field.NewPath("initContainers"), PodValidationOptions{}) if len(errs) == 0 { t.Fatal("expected error but received none") } @@ -18611,6 +18611,9 @@ func TestValidateOSFields(t *testing.T) { "Priority", "PriorityClassName", "ReadinessGates", + "ResourceClaims[*].Name", + "ResourceClaims[*].Source.ResourceClaimName", + "ResourceClaims[*].Source.ResourceClaimTemplateName", "RestartPolicy", "RuntimeClassName", "SchedulerName", @@ -20914,7 +20917,7 @@ func TestValidateResourceRequirements(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if errs := ValidateResourceRequirements(&tc.requirements, path, tc.opts); len(errs) != 0 { + if errs := ValidateResourceRequirements(&tc.requirements, nil, path, tc.opts); len(errs) != 0 { t.Errorf("unexpected errors: %v", errs) } }) @@ -20941,7 +20944,7 @@ func TestValidateResourceRequirements(t *testing.T) { for _, tc := range errTests { t.Run(tc.name, func(t *testing.T) { - if errs := ValidateResourceRequirements(&tc.requirements, path, tc.opts); len(errs) == 0 { + if errs := ValidateResourceRequirements(&tc.requirements, nil, path, tc.opts); len(errs) == 0 { t.Error("expected errors") } }) @@ -21681,3 +21684,220 @@ func TestValidatePVSecretReference(t *testing.T) { }) } } + +func TestValidateDynamicResourceAllocation(t *testing.T) { + externalClaimName := "some-claim" + externalClaimTemplateName := "some-claim-template" + goodClaimSource := core.ClaimSource{ + ResourceClaimName: &externalClaimName, + } + + successCases := map[string]core.PodSpec{ + "resource claim reference": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: core.ClaimSource{ + ResourceClaimName: &externalClaimName, + }, + }, + }, + }, + "resource claim template": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: core.ClaimSource{ + ResourceClaimTemplateName: &externalClaimTemplateName, + }, + }, + }, + }, + "multiple claims": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}, {Name: "another-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + { + Name: "another-claim", + Source: goodClaimSource, + }, + }, + }, + "init container": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + InitContainers: []core.Container{{Name: "ctr-init", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + } + for k, v := range successCases { + t.Run(k, func(t *testing.T) { + if errs := ValidatePodSpec(&v, nil, field.NewPath("field"), PodValidationOptions{}); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + }) + } + + failureCases := map[string]core.PodSpec{ + "pod claim name with prefix": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "../my-claim", + Source: goodClaimSource, + }, + }, + }, + "pod claim name with path": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my/claim", + Source: goodClaimSource, + }, + }, + }, + "pod claim name empty": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "", + Source: goodClaimSource, + }, + }, + }, + "duplicate pod claim entries": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + "resource claim source empty": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: core.ClaimSource{}, + }, + }, + }, + "resource claim reference and template": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: core.ClaimSource{ + ResourceClaimName: &externalClaimName, + ResourceClaimTemplateName: &externalClaimTemplateName, + }, + }, + }, + }, + "claim not found": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "no-such-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + "claim name empty": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: ""}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + "pod claim name duplicates": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}, {Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + "no claims defined": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + }, + "duplicate pod claim name": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + "ephemeral container don't support resource requirements": { + Containers: []core.Container{{Name: "ctr", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}}, + EphemeralContainers: []core.EphemeralContainer{{EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "ctr-ephemeral", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File", Resources: core.ResourceRequirements{Claims: []core.ResourceClaim{{Name: "my-claim"}}}}, TargetContainerName: "ctr"}}, + RestartPolicy: core.RestartPolicyAlways, + DNSPolicy: core.DNSClusterFirst, + ResourceClaims: []core.PodResourceClaim{ + { + Name: "my-claim", + Source: goodClaimSource, + }, + }, + }, + } + for k, v := range failureCases { + if errs := ValidatePodSpec(&v, nil, field.NewPath("field"), PodValidationOptions{}); len(errs) == 0 { + t.Errorf("expected failure for %q", k) + } + } +} diff --git a/pkg/apis/node/validation/validation.go b/pkg/apis/node/validation/validation.go index 60f6161611f..4e0a20aaf1e 100644 --- a/pkg/apis/node/validation/validation.go +++ b/pkg/apis/node/validation/validation.go @@ -54,7 +54,7 @@ func ValidateRuntimeClassUpdate(new, old *node.RuntimeClass) field.ErrorList { func validateOverhead(overhead *node.Overhead, fldPath *field.Path) field.ErrorList { // reuse the ResourceRequirements validation logic - return corevalidation.ValidateResourceRequirements(&core.ResourceRequirements{Limits: overhead.PodFixed}, fldPath, + return corevalidation.ValidateResourceRequirements(&core.ResourceRequirements{Limits: overhead.PodFixed}, nil, fldPath, corevalidation.PodValidationOptions{}) } diff --git a/staging/src/k8s.io/api/core/v1/types.go b/staging/src/k8s.io/api/core/v1/types.go index 0b8ecc6b270..87230fd9181 100644 --- a/staging/src/k8s.io/api/core/v1/types.go +++ b/staging/src/k8s.io/api/core/v1/types.go @@ -2314,6 +2314,26 @@ type ResourceRequirements struct { // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ // +optional Requests ResourceList `json:"requests,omitempty" protobuf:"bytes,2,rep,name=requests,casttype=ResourceList,castkey=ResourceName"` + // Claims lists the names of resources, defined in spec.resourceClaims, + // that are used by this container. + // + // This is an alpha field and requires enabling the + // DynamicResourceAllocation feature gate. + // + // This field is immutable. + // + // +listType=set + // +featureGate=DynamicResourceAllocation + // +optional + Claims []ResourceClaim `json:"claims,omitempty" protobuf:"bytes,3,opt,name=claims"` +} + +// ResourceClaim references one entry in PodSpec.ResourceClaims. +type ResourceClaim struct { + // Name must match the name of one entry in pod.spec.resourceClaims of + // the Pod where this field is used. It makes that resource available + // inside a container. + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` } const ( @@ -3347,6 +3367,7 @@ type PodSpec struct { // - spec.containers[*].securityContext.runAsGroup // +optional OS *PodOS `json:"os,omitempty" protobuf:"bytes,36,opt,name=os"` + // Use the host's user namespace. // Optional: Default to true. // If set to true or not present, the pod will be run in the host user namespace, useful @@ -3359,6 +3380,7 @@ type PodSpec struct { // +k8s:conversion-gen=false // +optional HostUsers *bool `json:"hostUsers,omitempty" protobuf:"bytes,37,opt,name=hostUsers"` + // SchedulingGates is an opaque list of values that if specified will block scheduling the pod. // More info: https://git.k8s.io/enhancements/keps/sig-scheduling/3521-pod-scheduling-readiness. // @@ -3369,6 +3391,65 @@ type PodSpec struct { // +listType=map // +listMapKey=name SchedulingGates []PodSchedulingGate `json:"schedulingGates,omitempty" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,38,opt,name=schedulingGates"` + // ResourceClaims defines which ResourceClaims must be allocated + // and reserved before the Pod is allowed to start. The resources + // will be made available to those containers which consume them + // by name. + // + // This is an alpha field and requires enabling the + // DynamicResourceAllocation feature gate. + // + // This field is immutable. + // + // +patchMergeKey=name + // +patchStrategy=merge,retainKeys + // +listType=map + // +listMapKey=name + // +featureGate=DynamicResourceAllocation + // +optional + ResourceClaims []PodResourceClaim `json:"resourceClaims,omitempty" patchStrategy:"merge,retainKeys" patchMergeKey:"name" protobuf:"bytes,39,rep,name=resourceClaims"` +} + +// PodResourceClaim references exactly one ResourceClaim through a ClaimSource. +// It adds a name to it that uniquely identifies the ResourceClaim inside the Pod. +// Containers that need access to the ResourceClaim reference it with this name. +type PodResourceClaim struct { + // Name uniquely identifies this resource claim inside the pod. + // This must be a DNS_LABEL. + Name string `json:"name" protobuf:"bytes,1,name=name"` + + // Source describes where to find the ResourceClaim. + Source ClaimSource `json:"source,omitempty" protobuf:"bytes,2,name=source"` +} + +// ClaimSource describes a reference to a ResourceClaim. +// +// Exactly one of these fields should be set. Consumers of this type must +// treat an empty object as if it has an unknown value. +type ClaimSource struct { + // ResourceClaimName is the name of a ResourceClaim object in the same + // namespace as this pod. + ResourceClaimName *string `json:"resourceClaimName,omitempty" protobuf:"bytes,1,opt,name=resourceClaimName"` + + // ResourceClaimTemplateName is the name of a ResourceClaimTemplate + // object in the same namespace as this pod. + // + // The template will be used to create a new ResourceClaim, which will + // be bound to this pod. When this pod is deleted, the ResourceClaim + // will also be deleted. The name of the ResourceClaim will be -, where is the + // PodResourceClaim.Name. Pod validation will reject the pod if the + // concatenated name is not valid for a ResourceClaim (e.g. too long). + // + // An existing ResourceClaim with that name that is not owned by the + // pod will not be used for the pod to avoid using an unrelated + // resource by mistake. Scheduling and pod startup are then blocked + // until the unrelated ResourceClaim is removed. + // + // This field is immutable and no changes will be made to the + // corresponding ResourceClaim by the control plane after creating the + // ResourceClaim. + ResourceClaimTemplateName *string `json:"resourceClaimTemplateName,omitempty" protobuf:"bytes,2,opt,name=resourceClaimTemplateName"` } // OSName is the set of OS'es that can be used in OS.