From 5cca60f0b86414c692b3eec3993f3a43364e232a Mon Sep 17 00:00:00 2001 From: Patrick Ohly Date: Fri, 4 Nov 2022 14:01:00 +0100 Subject: [PATCH] api: dynamic resource allocation API This adds a new resource.k8s.io API group with v1alpha1 as version. It contains four new types: resource.ResourceClaim, resource.ResourceClass, resource.ResourceClaimTemplate, and resource.PodScheduling. --- cmd/kube-apiserver/app/aggregator.go | 1 + hack/lib/init.sh | 1 + pkg/api/testing/defaulting_test.go | 4 + pkg/api/testing/fuzzer.go | 2 + pkg/api/testing/install.go | 1 + pkg/apis/resource/OWNERS | 6 + pkg/apis/resource/doc.go | 21 + pkg/apis/resource/fuzzer/fuzzer.go | 40 ++ pkg/apis/resource/install/install.go | 38 ++ pkg/apis/resource/install/install_test.go | 75 +++ pkg/apis/resource/register.go | 66 ++ pkg/apis/resource/types.go | 404 +++++++++++ pkg/apis/resource/v1alpha1/conversion.go | 25 + pkg/apis/resource/v1alpha1/defaults.go | 32 + pkg/apis/resource/v1alpha1/defaults_test.go | 75 +++ pkg/apis/resource/v1alpha1/doc.go | 23 + pkg/apis/resource/v1alpha1/register.go | 46 ++ pkg/apis/resource/validation/validation.go | 317 +++++++++ .../validation_podscheduling_test.go | 312 +++++++++ .../validation_resourceclaim_test.go | 629 ++++++++++++++++++ .../validation_resourceclaimtemplate_test.go | 313 +++++++++ .../validation_resourceclass_test.go | 282 ++++++++ pkg/controlplane/import_known_versions.go | 1 + pkg/controlplane/instance.go | 4 + pkg/kubectl/.import-restrictions | 3 + .../cmd/convert/import_known_versions.go | 1 + .../internalversion/import_known_versions.go | 1 + pkg/registry/resource/OWNERS | 6 + .../resource/podscheduling/storage/storage.go | 100 +++ .../podscheduling/storage/storage_test.go | 184 +++++ .../resource/podscheduling/strategy.go | 163 +++++ .../resource/podscheduling/strategy_test.go | 84 +++ .../resource/resourceclaim/storage/storage.go | 100 +++ .../resourceclaim/storage/storage_test.go | 182 +++++ .../resource/resourceclaim/strategy.go | 163 +++++ .../resource/resourceclaim/strategy_test.go | 85 +++ .../resourceclaimtemplate/storage/storage.go | 55 ++ .../storage/storage_test.go | 151 +++++ .../resourceclaimtemplate/strategy.go | 94 +++ .../resourceclaimtemplate/strategy_test.go | 87 +++ .../resource/resourceclass/storage/storage.go | 55 ++ .../resourceclass/storage/storage_test.go | 145 ++++ .../resource/resourceclass/strategy.go | 75 +++ .../resource/resourceclass/strategy_test.go | 81 +++ .../resource/rest/storage_resource.go | 91 +++ staging/src/k8s.io/api/resource/OWNERS | 6 + .../src/k8s.io/api/resource/v1alpha1/doc.go | 24 + .../k8s.io/api/resource/v1alpha1/register.go | 63 ++ .../src/k8s.io/api/resource/v1alpha1/types.go | 429 ++++++++++++ staging/src/k8s.io/api/roundtrip_test.go | 2 + .../apiserver/apply/reset_fields_test.go | 10 + .../apiserver/apply/status_test.go | 2 + test/integration/etcd/data.go | 19 + 53 files changed, 5179 insertions(+) create mode 100644 pkg/apis/resource/OWNERS create mode 100644 pkg/apis/resource/doc.go create mode 100644 pkg/apis/resource/fuzzer/fuzzer.go create mode 100644 pkg/apis/resource/install/install.go create mode 100644 pkg/apis/resource/install/install_test.go create mode 100644 pkg/apis/resource/register.go create mode 100644 pkg/apis/resource/types.go create mode 100644 pkg/apis/resource/v1alpha1/conversion.go create mode 100644 pkg/apis/resource/v1alpha1/defaults.go create mode 100644 pkg/apis/resource/v1alpha1/defaults_test.go create mode 100644 pkg/apis/resource/v1alpha1/doc.go create mode 100644 pkg/apis/resource/v1alpha1/register.go create mode 100644 pkg/apis/resource/validation/validation.go create mode 100644 pkg/apis/resource/validation/validation_podscheduling_test.go create mode 100644 pkg/apis/resource/validation/validation_resourceclaim_test.go create mode 100644 pkg/apis/resource/validation/validation_resourceclaimtemplate_test.go create mode 100644 pkg/apis/resource/validation/validation_resourceclass_test.go create mode 100644 pkg/registry/resource/OWNERS create mode 100644 pkg/registry/resource/podscheduling/storage/storage.go create mode 100644 pkg/registry/resource/podscheduling/storage/storage_test.go create mode 100644 pkg/registry/resource/podscheduling/strategy.go create mode 100644 pkg/registry/resource/podscheduling/strategy_test.go create mode 100644 pkg/registry/resource/resourceclaim/storage/storage.go create mode 100644 pkg/registry/resource/resourceclaim/storage/storage_test.go create mode 100644 pkg/registry/resource/resourceclaim/strategy.go create mode 100644 pkg/registry/resource/resourceclaim/strategy_test.go create mode 100644 pkg/registry/resource/resourceclaimtemplate/storage/storage.go create mode 100644 pkg/registry/resource/resourceclaimtemplate/storage/storage_test.go create mode 100644 pkg/registry/resource/resourceclaimtemplate/strategy.go create mode 100644 pkg/registry/resource/resourceclaimtemplate/strategy_test.go create mode 100644 pkg/registry/resource/resourceclass/storage/storage.go create mode 100644 pkg/registry/resource/resourceclass/storage/storage_test.go create mode 100644 pkg/registry/resource/resourceclass/strategy.go create mode 100644 pkg/registry/resource/resourceclass/strategy_test.go create mode 100644 pkg/registry/resource/rest/storage_resource.go create mode 100644 staging/src/k8s.io/api/resource/OWNERS create mode 100644 staging/src/k8s.io/api/resource/v1alpha1/doc.go create mode 100644 staging/src/k8s.io/api/resource/v1alpha1/register.go create mode 100644 staging/src/k8s.io/api/resource/v1alpha1/types.go diff --git a/cmd/kube-apiserver/app/aggregator.go b/cmd/kube-apiserver/app/aggregator.go index 2ad3b9ecb69..520fbae4956 100644 --- a/cmd/kube-apiserver/app/aggregator.go +++ b/cmd/kube-apiserver/app/aggregator.go @@ -283,6 +283,7 @@ var apiVersionPriorities = map[schema.GroupVersion]priority{ {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta1"}: {group: 16100, version: 12}, {Group: "flowcontrol.apiserver.k8s.io", Version: "v1alpha1"}: {group: 16100, version: 9}, {Group: "internal.apiserver.k8s.io", Version: "v1alpha1"}: {group: 16000, version: 9}, + {Group: "resource.k8s.io", Version: "v1alpha1"}: {group: 15900, version: 9}, // Append a new group to the end of the list if unsure. // You can use min(existing group)-100 as the initial value for a group. // Version can be set to 9 (to have space around) for a new group. diff --git a/hack/lib/init.sh b/hack/lib/init.sh index 51e3d1db195..17f69c91c53 100755 --- a/hack/lib/init.sh +++ b/hack/lib/init.sh @@ -89,6 +89,7 @@ coordination.k8s.io/v1beta1 \ coordination.k8s.io/v1 \ discovery.k8s.io/v1 \ discovery.k8s.io/v1beta1 \ +resource.k8s.io/v1alpha1 \ extensions/v1beta1 \ events.k8s.io/v1 \ events.k8s.io/v1beta1 \ diff --git a/pkg/api/testing/defaulting_test.go b/pkg/api/testing/defaulting_test.go index cd341e60a72..acafe9aca70 100644 --- a/pkg/api/testing/defaulting_test.go +++ b/pkg/api/testing/defaulting_test.go @@ -139,6 +139,10 @@ func TestDefaulting(t *testing.T) { {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRoleBindingList"}: {}, {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "RoleBinding"}: {}, {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "RoleBindingList"}: {}, + {Group: "resource.k8s.io", Version: "v1alpha1", Kind: "ResourceClaim"}: {}, + {Group: "resource.k8s.io", Version: "v1alpha1", Kind: "ResourceClaimList"}: {}, + {Group: "resource.k8s.io", Version: "v1alpha1", Kind: "ResourceClaimTemplate"}: {}, + {Group: "resource.k8s.io", Version: "v1alpha1", Kind: "ResourceClaimTemplateList"}: {}, {Group: "admissionregistration.k8s.io", Version: "v1alpha1", Kind: "ValidatingAdmissionPolicy"}: {}, {Group: "admissionregistration.k8s.io", Version: "v1alpha1", Kind: "ValidatingAdmissionPolicyList"}: {}, {Group: "admissionregistration.k8s.io", Version: "v1alpha1", Kind: "ValidatingAdmissionPolicyBinding"}: {}, diff --git a/pkg/api/testing/fuzzer.go b/pkg/api/testing/fuzzer.go index e43f0b8ebb6..e2773c78c2e 100644 --- a/pkg/api/testing/fuzzer.go +++ b/pkg/api/testing/fuzzer.go @@ -42,6 +42,7 @@ import ( networkingfuzzer "k8s.io/kubernetes/pkg/apis/networking/fuzzer" policyfuzzer "k8s.io/kubernetes/pkg/apis/policy/fuzzer" rbacfuzzer "k8s.io/kubernetes/pkg/apis/rbac/fuzzer" + resourcefuzzer "k8s.io/kubernetes/pkg/apis/resource/fuzzer" schedulingfuzzer "k8s.io/kubernetes/pkg/apis/scheduling/fuzzer" storagefuzzer "k8s.io/kubernetes/pkg/apis/storage/fuzzer" ) @@ -101,6 +102,7 @@ var FuzzerFuncs = fuzzer.MergeFuzzerFuncs( autoscalingfuzzer.Funcs, rbacfuzzer.Funcs, policyfuzzer.Funcs, + resourcefuzzer.Funcs, certificatesfuzzer.Funcs, admissionregistrationfuzzer.Funcs, storagefuzzer.Funcs, diff --git a/pkg/api/testing/install.go b/pkg/api/testing/install.go index 78b9a1a987e..e596d7e543c 100644 --- a/pkg/api/testing/install.go +++ b/pkg/api/testing/install.go @@ -37,6 +37,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" + _ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/scheduling/install" _ "k8s.io/kubernetes/pkg/apis/storage/install" ) diff --git a/pkg/apis/resource/OWNERS b/pkg/apis/resource/OWNERS new file mode 100644 index 00000000000..98397072ae5 --- /dev/null +++ b/pkg/apis/resource/OWNERS @@ -0,0 +1,6 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +reviewers: + - bart0sh + - klueska + - pohly diff --git a/pkg/apis/resource/doc.go b/pkg/apis/resource/doc.go new file mode 100644 index 00000000000..f798bef1f94 --- /dev/null +++ b/pkg/apis/resource/doc.go @@ -0,0 +1,21 @@ +/* +Copyright 2022 The Kubernetes 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. +*/ + +// +k8s:deepcopy-gen=package + +// Package resource contains the latest (or "internal") version of the +// Kubernetes resource API objects. +package resource // import "k8s.io/kubernetes/pkg/apis/resource" diff --git a/pkg/apis/resource/fuzzer/fuzzer.go b/pkg/apis/resource/fuzzer/fuzzer.go new file mode 100644 index 00000000000..5cf5b5e635b --- /dev/null +++ b/pkg/apis/resource/fuzzer/fuzzer.go @@ -0,0 +1,40 @@ +/* +Copyright 2022 The Kubernetes 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 fuzzer + +import ( + fuzz "github.com/google/gofuzz" + + runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/kubernetes/pkg/apis/resource" +) + +// Funcs contains the fuzzer functions for the resource group. +var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} { + return []interface{}{ + func(obj *resource.ResourceClaimSpec, c fuzz.Continue) { + c.FuzzNoCustom(obj) // fuzz self without calling this function again + + // Custom fuzzing for allocation mode: pick one valid mode randomly. + modes := []resource.AllocationMode{ + resource.AllocationModeImmediate, + resource.AllocationModeWaitForFirstConsumer, + } + obj.AllocationMode = modes[c.Rand.Intn(len(modes))] + }, + } +} diff --git a/pkg/apis/resource/install/install.go b/pkg/apis/resource/install/install.go new file mode 100644 index 00000000000..f0495e7468a --- /dev/null +++ b/pkg/apis/resource/install/install.go @@ -0,0 +1,38 @@ +/* +Copyright 2022 The Kubernetes 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 install installs the resource API, making it available as an +// option to all of the API encoding/decoding machinery. +package install + +import ( + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/apis/resource/v1alpha1" +) + +func init() { + Install(legacyscheme.Scheme) +} + +// Install registers the API group and adds types to a scheme +func Install(scheme *runtime.Scheme) { + utilruntime.Must(resource.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(scheme.SetVersionPriority(v1alpha1.SchemeGroupVersion)) +} diff --git a/pkg/apis/resource/install/install_test.go b/pkg/apis/resource/install/install_test.go new file mode 100644 index 00000000000..ed034a71d77 --- /dev/null +++ b/pkg/apis/resource/install/install_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2022 The Kubernetes 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 install + +import ( + "encoding/json" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kubernetes/pkg/api/legacyscheme" + internal "k8s.io/kubernetes/pkg/apis/resource" +) + +func TestResourceVersioner(t *testing.T) { + claim := internal.ResourceClaim{ObjectMeta: metav1.ObjectMeta{ResourceVersion: "10"}} + version, err := meta.NewAccessor().ResourceVersion(&claim) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "10" { + t.Errorf("unexpected version %v", version) + } + + claimList := internal.ResourceClaimList{ListMeta: metav1.ListMeta{ResourceVersion: "10"}} + version, err = meta.NewAccessor().ResourceVersion(&claimList) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "10" { + t.Errorf("unexpected version %v", version) + } +} + +func TestCodec(t *testing.T) { + claim := internal.ResourceClaim{} + data, err := runtime.Encode(legacyscheme.Codecs.LegacyCodec(schema.GroupVersion{Group: "resource.k8s.io", Version: "v1alpha1"}), &claim) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + other := internal.ResourceClaim{} + if err := json.Unmarshal(data, &other); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if other.APIVersion != "resource.k8s.io/v1alpha1" || other.Kind != "ResourceClaim" { + t.Errorf("unexpected unmarshalled object %#v", other) + } +} + +func TestUnversioned(t *testing.T) { + for _, obj := range []runtime.Object{ + &metav1.Status{}, + } { + if unversioned, ok := legacyscheme.Scheme.IsUnversioned(obj); !unversioned || !ok { + t.Errorf("%v is expected to be unversioned", reflect.TypeOf(obj)) + } + } +} diff --git a/pkg/apis/resource/register.go b/pkg/apis/resource/register.go new file mode 100644 index 00000000000..35b89de0fb1 --- /dev/null +++ b/pkg/apis/resource/register.go @@ -0,0 +1,66 @@ +/* +Copyright 2022 The Kubernetes 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 resource + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "resource.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} + +// Kind takes an unqualified kind and returns a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // SchemeBuilder object to register various known types + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + + // AddToScheme represents a func that can be used to apply all the registered + // funcs in a scheme + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + if err := scheme.AddIgnoredConversionType(&metav1.TypeMeta{}, &metav1.TypeMeta{}); err != nil { + return err + } + scheme.AddKnownTypes(SchemeGroupVersion, + &ResourceClass{}, + &ResourceClassList{}, + &ResourceClaim{}, + &ResourceClaimList{}, + &ResourceClaimTemplate{}, + &ResourceClaimTemplateList{}, + &PodScheduling{}, + &PodSchedulingList{}, + ) + + return nil +} diff --git a/pkg/apis/resource/types.go b/pkg/apis/resource/types.go new file mode 100644 index 00000000000..627a09cb425 --- /dev/null +++ b/pkg/apis/resource/types.go @@ -0,0 +1,404 @@ +/* +Copyright 2022 The Kubernetes 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 resource + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/kubernetes/pkg/apis/core" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClaim describes which resources are needed by a resource consumer. +// Its status tracks whether the resource has been allocated and what the +// resulting attributes are. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type ResourceClaim struct { + metav1.TypeMeta + // Standard object metadata + // +optional + metav1.ObjectMeta + + // Spec describes the desired attributes of a resource that then needs + // to be allocated. It can only be set once when creating the + // ResourceClaim. + Spec ResourceClaimSpec + + // Status describes whether the resource is available and with which + // attributes. + // +optional + Status ResourceClaimStatus +} + +// ResourceClaimSpec defines how a resource is to be allocated. +type ResourceClaimSpec struct { + // ResourceClassName references the driver and additional parameters + // via the name of a ResourceClass that was created as part of the + // driver deployment. + ResourceClassName string + + // ParametersRef references a separate object with arbitrary parameters + // that will be used by the driver when allocating a resource for the + // claim. + // + // The object must be in the same namespace as the ResourceClaim. + // +optional + ParametersRef *ResourceClaimParametersReference + + // Allocation can start immediately or when a Pod wants to use the + // resource. "WaitForFirstConsumer" is the default. + // +optional + AllocationMode AllocationMode +} + +// AllocationMode describes whether a ResourceClaim gets allocated immediately +// when it gets created (AllocationModeImmediate) or whether allocation is +// delayed until it is needed for a Pod +// (AllocationModeWaitForFirstConsumer). Other modes might get added in the +// future. +type AllocationMode string + +const ( + // When a ResourceClaim has AllocationModeWaitForFirstConsumer, allocation is + // delayed until a Pod gets scheduled that needs the ResourceClaim. The + // scheduler will consider all resource requirements of that Pod and + // trigger allocation for a node that fits the Pod. + AllocationModeWaitForFirstConsumer AllocationMode = "WaitForFirstConsumer" + + // When a ResourceClaim has AllocationModeImmediate, allocation starts + // as soon as the ResourceClaim gets created. This is done without + // considering the needs of Pods that will use the ResourceClaim + // because those Pods are not known yet. + AllocationModeImmediate AllocationMode = "Immediate" +) + +// ResourceClaimStatus tracks whether the resource has been allocated and what +// the resulting attributes are. +type ResourceClaimStatus struct { + // DriverName is a copy of the driver name from the ResourceClass at + // the time when allocation started. + // +optional + DriverName string + + // Allocation is set by the resource driver once a resource has been + // allocated successfully. If this is not specified, the resource is + // not yet allocated. + // +optional + Allocation *AllocationResult + + // ReservedFor indicates which entities are currently allowed to use + // the claim. A Pod which references a ResourceClaim which is not + // reserved for that Pod will not be started. + // + // There can be at most 32 such reservations. This may get increased in + // the future, but not reduced. + // +optional + ReservedFor []ResourceClaimConsumerReference + + // DeallocationRequested indicates that a ResourceClaim is to be + // deallocated. + // + // The driver then must deallocate this claim and reset the field + // together with clearing the Allocation field. + // + // While DeallocationRequested is set, no new consumers may be added to + // ReservedFor. + // +optional + DeallocationRequested bool +} + +// ReservedForMaxSize is the maximum number of entries in +// claim.status.reservedFor. +const ResourceClaimReservedForMaxSize = 32 + +// AllocationResult contains attributed of an allocated resource. +type AllocationResult struct { + // ResourceHandle contains arbitrary data returned by the driver after a + // successful allocation. This is opaque for + // Kubernetes. Driver documentation may explain to users how to + // interpret this data if needed. + // + // The maximum size of this field is 16KiB. This may get + // increased in the future, but not reduced. + // +optional + ResourceHandle string + + // This field will get set by the resource driver after it has + // allocated the resource driver to inform the scheduler where it can + // schedule Pods using the ResourceClaim. + // + // Setting this field is optional. If null, the resource is available + // everywhere. + // +optional + AvailableOnNodes *core.NodeSelector + + // Shareable determines whether the resource supports more + // than one consumer at a time. + // +optional + Shareable bool +} + +// ResourceHandleMaxSize is the maximum size of allocation.resourceHandle. +const ResourceHandleMaxSize = 16 * 1024 + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClaimList is a collection of claims. +type ResourceClaimList struct { + metav1.TypeMeta + // Standard list metadata + // +optional + metav1.ListMeta + + // Items is the list of resource claims. + Items []ResourceClaim +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// PodScheduling objects hold information that is needed to schedule +// a Pod with ResourceClaims that use "WaitForFirstConsumer" allocation +// mode. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type PodScheduling struct { + metav1.TypeMeta + // Standard object metadata + // +optional + metav1.ObjectMeta + + // Spec describes where resources for the Pod are needed. + Spec PodSchedulingSpec + + // Status describes where resources for the Pod can be allocated. + Status PodSchedulingStatus +} + +// PodSchedulingSpec describes where resources for the Pod are needed. +type PodSchedulingSpec struct { + // SelectedNode is the node for which allocation of ResourceClaims that + // are referenced by the Pod and that use "WaitForFirstConsumer" + // allocation is to be attempted. + SelectedNode string + + // PotentialNodes lists nodes where the Pod might be able to run. + // + // The size of this field is limited to 128. This is large enough for + // many clusters. Larger clusters may need more attempts to find a node + // that suits all pending resources. This may get increased in the + // future, but not reduced. + // +optional + PotentialNodes []string +} + +// PodSchedulingStatus describes where resources for the Pod can be allocated. +type PodSchedulingStatus struct { + // ResourceClaims describes resource availability for each + // pod.spec.resourceClaim entry where the corresponding ResourceClaim + // uses "WaitForFirstConsumer" allocation mode. + // +optional + ResourceClaims []ResourceClaimSchedulingStatus + + // If there ever is a need to support other kinds of resources + // than ResourceClaim, then new fields could get added here + // for those other resources. +} + +// ResourceClaimSchedulingStatus contains information about one particular +// ResourceClaim with "WaitForFirstConsumer" allocation mode. +type ResourceClaimSchedulingStatus struct { + // Name matches the pod.spec.resourceClaims[*].Name field. + Name string + + // UnsuitableNodes lists nodes that the ResourceClaim cannot be + // allocated for. + // + // The size of this field is limited to 128, the same as for + // PodSchedulingSpec.PotentialNodes. This may get increased in the + // future, but not reduced. + // +optional + UnsuitableNodes []string +} + +// PodSchedulingNodeListMaxSize defines the maximum number of entries in the +// node lists that are stored in PodScheduling objects. This limit is part +// of the API. +const PodSchedulingNodeListMaxSize = 128 + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// PodSchedulingList is a collection of Pod scheduling objects. +type PodSchedulingList struct { + metav1.TypeMeta + // Standard list metadata + // +optional + metav1.ListMeta + + // Items is the list of PodScheduling objects. + Items []PodScheduling +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClass is used by administrators to influence how resources +// are allocated. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type ResourceClass struct { + metav1.TypeMeta + // Standard object metadata + // +optional + metav1.ObjectMeta + + // DriverName defines the name of the dynamic resource driver that is + // used for allocation of a ResourceClaim that uses this class. + // + // Resource drivers have a unique name in forward domain order + // (acme.example.com). + DriverName string + + // ParametersRef references an arbitrary separate object that may hold + // parameters that will be used by the driver when allocating a + // resource that uses this class. A dynamic resource driver can + // distinguish between parameters stored here and and those stored in + // ResourceClaimSpec. + // +optional + ParametersRef *ResourceClassParametersReference + + // Only nodes matching the selector will be considered by the scheduler + // when trying to find a Node that fits a Pod when that Pod uses + // a ResourceClaim that has not been allocated yet. + // + // Setting this field is optional. If null, all nodes are candidates. + // +optional + SuitableNodes *core.NodeSelector +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClassList is a collection of classes. +type ResourceClassList struct { + metav1.TypeMeta + // Standard list metadata + // +optional + metav1.ListMeta + + // Items is the list of resource classes. + Items []ResourceClass +} + +// ResourceClassParametersReference contains enough information to let you +// locate the parameters for a ResourceClass. +type ResourceClassParametersReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string + // Kind is the type of resource being referenced. This is the same + // value as in the parameter object's metadata. + Kind string + // Name is the name of resource being referenced. + Name string + // Namespace that contains the referenced resource. Must be empty + // for cluster-scoped resources and non-empty for namespaced + // resources. + // +optional + Namespace string +} + +// ResourceClaimParametersReference contains enough information to let you +// locate the parameters for a ResourceClaim. The object must be in the same +// namespace as the ResourceClaim. +type ResourceClaimParametersReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string + // Kind is the type of resource being referenced. This is the same + // value as in the parameter object's metadata, for example "ConfigMap". + Kind string + // Name is the name of resource being referenced. + Name string +} + +// ResourceClaimConsumerReference contains enough information to let you +// locate the consumer of a ResourceClaim. The user must be a resource in the same +// namespace as the ResourceClaim. +type ResourceClaimConsumerReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string + // Resource is the type of resource being referenced, for example "pods". + Resource string + // Name is the name of resource being referenced. + Name string + // UID identifies exactly one incarnation of the resource. + UID types.UID +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClaimTemplate is used to produce ResourceClaim objects. +type ResourceClaimTemplate struct { + metav1.TypeMeta + // Standard object metadata + // +optional + metav1.ObjectMeta + + // Describes the ResourceClaim that is to be generated. + // + // This field is immutable. A ResourceClaim will get created by the + // control plane for a Pod when needed and then not get updated + // anymore. + Spec ResourceClaimTemplateSpec +} + +// ResourceClaimTemplateSpec contains the metadata and fields for a ResourceClaim. +type ResourceClaimTemplateSpec struct { + // ObjectMeta may contain labels and annotations that will be copied into the PVC + // when creating it. No other fields are allowed and will be rejected during + // validation. + // +optional + metav1.ObjectMeta + + // Spec for the ResourceClaim. The entire content is copied unchanged + // into the ResourceClaim that gets created from this template. The + // same fields as in a ResourceClaim are also valid here. + Spec ResourceClaimSpec +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ResourceClaimTemplateList is a collection of claim templates. +type ResourceClaimTemplateList struct { + metav1.TypeMeta + // Standard list metadata + // +optional + metav1.ListMeta + + // Items is the list of resource claim templates. + Items []ResourceClaimTemplate +} diff --git a/pkg/apis/resource/v1alpha1/conversion.go b/pkg/apis/resource/v1alpha1/conversion.go new file mode 100644 index 00000000000..25db58993fd --- /dev/null +++ b/pkg/apis/resource/v1alpha1/conversion.go @@ -0,0 +1,25 @@ +/* +Copyright 2022 The Kubernetes 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 ( + "k8s.io/apimachinery/pkg/runtime" +) + +func addConversionFuncs(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/resource/v1alpha1/defaults.go b/pkg/apis/resource/v1alpha1/defaults.go new file mode 100644 index 00000000000..8bc728f389c --- /dev/null +++ b/pkg/apis/resource/v1alpha1/defaults.go @@ -0,0 +1,32 @@ +/* +Copyright 2022 The Kubernetes 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 ( + "k8s.io/api/resource/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" +) + +func addDefaultingFuncs(scheme *runtime.Scheme) error { + return RegisterDefaults(scheme) +} + +func SetDefaults_ResourceClaimSpec(obj *v1alpha1.ResourceClaimSpec) { + if obj.AllocationMode == "" { + obj.AllocationMode = v1alpha1.AllocationModeWaitForFirstConsumer + } +} diff --git a/pkg/apis/resource/v1alpha1/defaults_test.go b/pkg/apis/resource/v1alpha1/defaults_test.go new file mode 100644 index 00000000000..01dafd4fda4 --- /dev/null +++ b/pkg/apis/resource/v1alpha1/defaults_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2022 The Kubernetes 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_test + +import ( + "reflect" + "testing" + + v1alpha1 "k8s.io/api/resource/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + + // ensure types are installed + "k8s.io/kubernetes/pkg/api/legacyscheme" + _ "k8s.io/kubernetes/pkg/apis/resource/install" +) + +func TestSetDefaultAllocationMode(t *testing.T) { + claim := &v1alpha1.ResourceClaim{} + + // field should be defaulted + defaultMode := v1alpha1.AllocationModeWaitForFirstConsumer + output := roundTrip(t, runtime.Object(claim)).(*v1alpha1.ResourceClaim) + outMode := output.Spec.AllocationMode + if outMode != defaultMode { + t.Errorf("Expected AllocationMode to be defaulted to: %+v, got: %+v", defaultMode, outMode) + } + + // field should not change + nonDefaultMode := v1alpha1.AllocationModeImmediate + claim = &v1alpha1.ResourceClaim{ + Spec: v1alpha1.ResourceClaimSpec{ + AllocationMode: nonDefaultMode, + }, + } + output = roundTrip(t, runtime.Object(claim)).(*v1alpha1.ResourceClaim) + outMode = output.Spec.AllocationMode + if outMode != v1alpha1.AllocationModeImmediate { + t.Errorf("Expected AllocationMode to remain %+v, got: %+v", nonDefaultMode, outMode) + } +} + +func roundTrip(t *testing.T, obj runtime.Object) runtime.Object { + codec := legacyscheme.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion) + data, err := runtime.Encode(codec, obj) + if err != nil { + t.Errorf("%v\n %#v", err, obj) + return nil + } + obj2, err := runtime.Decode(codec, data) + if err != nil { + t.Errorf("%v\nData: %s\nSource: %#v", err, string(data), obj) + return nil + } + obj3 := reflect.New(reflect.TypeOf(obj).Elem()).Interface().(runtime.Object) + err = legacyscheme.Scheme.Convert(obj2, obj3, nil) + if err != nil { + t.Errorf("%v\nSource: %#v", err, obj2) + return nil + } + return obj3 +} diff --git a/pkg/apis/resource/v1alpha1/doc.go b/pkg/apis/resource/v1alpha1/doc.go new file mode 100644 index 00000000000..77afe07224b --- /dev/null +++ b/pkg/apis/resource/v1alpha1/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Kubernetes 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. +*/ + +// +k8s:conversion-gen=k8s.io/kubernetes/pkg/apis/resource +// +k8s:conversion-gen-external-types=k8s.io/api/resource/v1alpha1 +// +k8s:defaulter-gen=TypeMeta +// +k8s:defaulter-gen-input=k8s.io/api/resource/v1alpha1 + +// Package v1alpha1 is the v1alpha1 version of the resource API. +package v1alpha1 // import "k8s.io/kubernetes/pkg/apis/resource/v1alpha1" diff --git a/pkg/apis/resource/v1alpha1/register.go b/pkg/apis/resource/v1alpha1/register.go new file mode 100644 index 00000000000..cd225b259dc --- /dev/null +++ b/pkg/apis/resource/v1alpha1/register.go @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Kubernetes 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 ( + "k8s.io/api/resource/v1alpha1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + localSchemeBuilder = &v1alpha1.SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addDefaultingFuncs, addConversionFuncs) +} + +// TODO: remove these global variables +// GroupName is the group name use in this package +const GroupName = "resource.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/resource/validation/validation.go b/pkg/apis/resource/validation/validation.go new file mode 100644 index 00000000000..9191bf5a348 --- /dev/null +++ b/pkg/apis/resource/validation/validation.go @@ -0,0 +1,317 @@ +/* +Copyright 2022 The Kubernetes 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 validation + +import ( + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" + corevalidation "k8s.io/kubernetes/pkg/apis/core/validation" + "k8s.io/kubernetes/pkg/apis/resource" +) + +// validateResourceClaimName can be used to check whether the given +// name for a ResourceClaim is valid. +var validateResourceClaimName = apimachineryvalidation.NameIsDNSSubdomain + +// validateResourceClaimTemplateName can be used to check whether the given +// name for a ResourceClaimTemplate is valid. +var validateResourceClaimTemplateName = apimachineryvalidation.NameIsDNSSubdomain + +// validateResourceDriverName reuses the validation of a CSI driver because +// the allowed values are exactly the same. +var validateResourceDriverName = corevalidation.ValidateCSIDriverName + +// ValidateClaim validates a ResourceClaim. +func ValidateClaim(resourceClaim *resource.ResourceClaim) field.ErrorList { + allErrs := corevalidation.ValidateObjectMeta(&resourceClaim.ObjectMeta, true, validateResourceClaimName, field.NewPath("metadata")) + allErrs = append(allErrs, validateResourceClaimSpec(&resourceClaim.Spec, field.NewPath("spec"))...) + return allErrs +} + +func validateResourceClaimSpec(spec *resource.ResourceClaimSpec, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + for _, msg := range corevalidation.ValidateClassName(spec.ResourceClassName, false) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("resourceClassName"), spec.ResourceClassName, msg)) + } + allErrs = append(allErrs, validateResourceClaimParameters(spec.ParametersRef, fldPath.Child("parametersRef"))...) + if !supportedAllocationModes.Has(string(spec.AllocationMode)) { + allErrs = append(allErrs, field.NotSupported(fldPath.Child("allocationMode"), spec.AllocationMode, supportedAllocationModes.List())) + } + return allErrs +} + +var supportedAllocationModes = sets.NewString(string(resource.AllocationModeImmediate), string(resource.AllocationModeWaitForFirstConsumer)) + +// It would have been nice to use Go generics to reuse the same validation +// function for Kind and Name in both types, but generics cannot be used to +// access common fields in structs. + +func validateResourceClaimParameters(ref *resource.ResourceClaimParametersReference, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if ref != nil { + if ref.Kind == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("kind"), "")) + } + if ref.Name == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("name"), "")) + } + } + return allErrs +} + +func validateClassParameters(ref *resource.ResourceClassParametersReference, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if ref != nil { + if ref.Kind == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("kind"), "")) + } + if ref.Name == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("name"), "")) + } + if ref.Namespace != "" { + for _, msg := range apimachineryvalidation.ValidateNamespaceName(ref.Namespace, false) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("namespace"), ref.Namespace, msg)) + } + } + } + return allErrs +} + +// ValidateClass validates a ResourceClass. +func ValidateClass(resourceClass *resource.ResourceClass) field.ErrorList { + allErrs := corevalidation.ValidateObjectMeta(&resourceClass.ObjectMeta, false, corevalidation.ValidateClassName, field.NewPath("metadata")) + allErrs = append(allErrs, validateResourceDriverName(resourceClass.DriverName, field.NewPath("driverName"))...) + allErrs = append(allErrs, validateClassParameters(resourceClass.ParametersRef, field.NewPath("parametersRef"))...) + if resourceClass.SuitableNodes != nil { + allErrs = append(allErrs, corevalidation.ValidateNodeSelector(resourceClass.SuitableNodes, field.NewPath("suitableNodes"))...) + } + + return allErrs +} + +// ValidateClassUpdate tests if an update to ResourceClass is valid. +func ValidateClassUpdate(resourceClass, oldClass *resource.ResourceClass) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&resourceClass.ObjectMeta, &oldClass.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, ValidateClass(resourceClass)...) + return allErrs +} + +// ValidateClaimUpdate tests if an update to ResourceClaim is valid. +func ValidateClaimUpdate(resourceClaim, oldClaim *resource.ResourceClaim) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&resourceClaim.ObjectMeta, &oldClaim.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, apimachineryvalidation.ValidateImmutableField(resourceClaim.Spec, oldClaim.Spec, field.NewPath("spec"))...) + allErrs = append(allErrs, ValidateClaim(resourceClaim)...) + return allErrs +} + +// ValidateClaimStatusUpdate tests if an update to the status of a ResourceClaim is valid. +func ValidateClaimStatusUpdate(resourceClaim, oldClaim *resource.ResourceClaim) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&resourceClaim.ObjectMeta, &oldClaim.ObjectMeta, field.NewPath("metadata")) + fldPath := field.NewPath("status") + // The name might not be set yet. + if resourceClaim.Status.DriverName != "" { + allErrs = append(allErrs, validateResourceDriverName(resourceClaim.Status.DriverName, fldPath.Child("driverName"))...) + } else if resourceClaim.Status.Allocation != nil { + allErrs = append(allErrs, field.Required(fldPath.Child("driverName"), "must be specified when `allocation` is set")) + } + + allErrs = append(allErrs, validateAllocationResult(resourceClaim.Status.Allocation, fldPath.Child("allocation"))...) + allErrs = append(allErrs, validateSliceIsASet(resourceClaim.Status.ReservedFor, resource.ResourceClaimReservedForMaxSize, + validateResourceClaimUserReference, fldPath.Child("reservedFor"))...) + + // Now check for invariants that must be valid for a ResourceClaim. + if len(resourceClaim.Status.ReservedFor) > 0 { + if resourceClaim.Status.Allocation == nil { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("reservedFor"), "may not be specified when `allocated` is not set")) + } else { + if !resourceClaim.Status.Allocation.Shareable && len(resourceClaim.Status.ReservedFor) > 1 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("reservedFor"), "may not be reserved more than once")) + } + // Items may be removed from ReservedFor while the claim is meant to be deallocated, + // but not added. + if resourceClaim.DeletionTimestamp != nil || resourceClaim.Status.DeallocationRequested { + oldSet := sets.New(oldClaim.Status.ReservedFor...) + newSet := sets.New(resourceClaim.Status.ReservedFor...) + newItems := newSet.Difference(oldSet) + if len(newItems) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("reservedFor"), "new entries may not be added while `deallocationRequested` or `deletionTimestamp` are set")) + } + } + } + } + + if !oldClaim.Status.DeallocationRequested && + resourceClaim.Status.DeallocationRequested && + len(resourceClaim.Status.ReservedFor) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("deallocationRequested"), "deallocation cannot be requested while `reservedFor` is set")) + } + + if resourceClaim.Status.Allocation == nil && + resourceClaim.Status.DeallocationRequested { + // Either one or the other field was modified incorrectly. + // For the sake of simplicity this only reports the invalid + // end result. + allErrs = append(allErrs, field.Forbidden(fldPath, "`allocation` must be set when `deallocationRequested` is set")) + } + + // Once deallocation has been requested, that request cannot be removed + // anymore because the deallocation may already have started. The field + // can only get reset by the driver together with removing the + // allocation. + if oldClaim.Status.DeallocationRequested && + !resourceClaim.Status.DeallocationRequested && + resourceClaim.Status.Allocation != nil { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("deallocationRequested"), "may not be cleared when `allocation` is set")) + } + + return allErrs +} + +func validateAllocationResult(allocation *resource.AllocationResult, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if allocation != nil { + if len(allocation.ResourceHandle) > resource.ResourceHandleMaxSize { + allErrs = append(allErrs, field.TooLongMaxLength(fldPath.Child("resourceHandle"), len(allocation.ResourceHandle), resource.ResourceHandleMaxSize)) + } + if allocation.AvailableOnNodes != nil { + allErrs = append(allErrs, corevalidation.ValidateNodeSelector(allocation.AvailableOnNodes, fldPath.Child("availableOnNodes"))...) + } + } + return allErrs +} + +func validateResourceClaimUserReference(ref resource.ResourceClaimConsumerReference, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + if ref.Resource == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("resource"), "")) + } + if ref.Name == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("name"), "")) + } + if ref.UID == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("uid"), "")) + } + return allErrs +} + +// validateSliceIsASet ensures that a slice contains no duplicates and does not exceed a certain maximum size. +func validateSliceIsASet[T comparable](slice []T, maxSize int, validateItem func(item T, fldPath *field.Path) field.ErrorList, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + allItems := sets.New[T]() + for i, item := range slice { + idxPath := fldPath.Index(i) + if allItems.Has(item) { + allErrs = append(allErrs, field.Duplicate(idxPath, item)) + } else { + allErrs = append(allErrs, validateItem(item, idxPath)...) + allItems.Insert(item) + } + } + if len(slice) > maxSize { + // Dumping the entire field into the error message is likely to be too long, + // in particular when it is already beyond the maximum size. Instead this + // just shows the number of entries. + allErrs = append(allErrs, field.TooLongMaxLength(fldPath, len(slice), maxSize)) + } + return allErrs +} + +// ValidatePodScheduling validates a PodScheduling. +func ValidatePodScheduling(resourceClaim *resource.PodScheduling) field.ErrorList { + allErrs := corevalidation.ValidateObjectMeta(&resourceClaim.ObjectMeta, true, corevalidation.ValidatePodName, field.NewPath("metadata")) + allErrs = append(allErrs, validatePodSchedulingSpec(&resourceClaim.Spec, field.NewPath("spec"))...) + return allErrs +} + +func validatePodSchedulingSpec(spec *resource.PodSchedulingSpec, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + // Checking PotentialNodes for duplicates is intentionally not done. It + // could be fairly expensive and the only component which normally has + // permissions to set this field, kube-scheduler, is a trusted + // component. Also, if it gets this wrong because of a bug, then the + // effect is limited (same semantic). + if len(spec.PotentialNodes) > resource.PodSchedulingNodeListMaxSize { + allErrs = append(allErrs, field.TooLongMaxLength(fldPath.Child("potentialNodes"), nil, resource.PodSchedulingNodeListMaxSize)) + } + return allErrs +} + +// ValidatePodSchedulingUpdate tests if an update to PodScheduling is valid. +func ValidatePodSchedulingUpdate(resourceClaim, oldPodScheduling *resource.PodScheduling) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&resourceClaim.ObjectMeta, &oldPodScheduling.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, ValidatePodScheduling(resourceClaim)...) + return allErrs +} + +// ValidatePodSchedulingStatusUpdate tests if an update to the status of a PodScheduling is valid. +func ValidatePodSchedulingStatusUpdate(resourceClaim, oldPodScheduling *resource.PodScheduling) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&resourceClaim.ObjectMeta, &oldPodScheduling.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, validatePodSchedulingStatus(&resourceClaim.Status, field.NewPath("status"))...) + return allErrs +} + +func validatePodSchedulingStatus(status *resource.PodSchedulingStatus, fldPath *field.Path) field.ErrorList { + return validatePodSchedulingClaims(status.ResourceClaims, fldPath.Child("claims")) +} + +func validatePodSchedulingClaims(claimStatuses []resource.ResourceClaimSchedulingStatus, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + names := sets.NewString() + for i, claimStatus := range claimStatuses { + allErrs = append(allErrs, validatePodSchedulingClaim(claimStatus, fldPath.Index(i))...) + if names.Has(claimStatus.Name) { + allErrs = append(allErrs, field.Duplicate(fldPath.Index(i), claimStatus.Name)) + } else { + names.Insert(claimStatus.Name) + } + } + return allErrs +} + +func validatePodSchedulingClaim(claim resource.ResourceClaimSchedulingStatus, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + // Checking UnsuitableNodes for duplicates is intentionally not done. It + // could be fairly expensive and if a resource driver gets this wrong, + // then it is only going to have a negative effect for the pods relying + // on this driver. + if len(claim.UnsuitableNodes) > resource.PodSchedulingNodeListMaxSize { + allErrs = append(allErrs, field.TooLongMaxLength(fldPath.Child("unsuitableNodes"), nil, resource.PodSchedulingNodeListMaxSize)) + } + return allErrs +} + +// ValidateClaimTemplace validates a ResourceClaimTemplate. +func ValidateClaimTemplate(template *resource.ResourceClaimTemplate) field.ErrorList { + allErrs := corevalidation.ValidateObjectMeta(&template.ObjectMeta, true, validateResourceClaimTemplateName, field.NewPath("metadata")) + allErrs = append(allErrs, validateResourceClaimTemplateSpec(&template.Spec, field.NewPath("spec"))...) + return allErrs +} + +func validateResourceClaimTemplateSpec(spec *resource.ResourceClaimTemplateSpec, fldPath *field.Path) field.ErrorList { + allErrs := corevalidation.ValidateTemplateObjectMeta(&spec.ObjectMeta, fldPath.Child("metadata")) + allErrs = append(allErrs, validateResourceClaimSpec(&spec.Spec, fldPath.Child("spec"))...) + return allErrs +} + +// ValidateClaimTemplateUpdate tests if an update to template is valid. +func ValidateClaimTemplateUpdate(template, oldTemplate *resource.ResourceClaimTemplate) field.ErrorList { + allErrs := corevalidation.ValidateObjectMetaUpdate(&template.ObjectMeta, &oldTemplate.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, apimachineryvalidation.ValidateImmutableField(template.Spec, oldTemplate.Spec, field.NewPath("spec"))...) + allErrs = append(allErrs, ValidateClaimTemplate(template)...) + return allErrs +} diff --git a/pkg/apis/resource/validation/validation_podscheduling_test.go b/pkg/apis/resource/validation/validation_podscheduling_test.go new file mode 100644 index 00000000000..138f70004bf --- /dev/null +++ b/pkg/apis/resource/validation/validation_podscheduling_test.go @@ -0,0 +1,312 @@ +/* +Copyright 2022 The Kubernetes 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 validation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/utils/pointer" +) + +func testPodScheduling(name, namespace string, spec resource.PodSchedulingSpec) *resource.PodScheduling { + return &resource.PodScheduling{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: spec, + } +} + +func TestValidatePodScheduling(t *testing.T) { + goodName := "foo" + goodNS := "ns" + goodPodSchedulingSpec := resource.PodSchedulingSpec{} + now := metav1.Now() + badName := "!@#$%^" + badValue := "spaces not allowed" + + scenarios := map[string]struct { + scheduling *resource.PodScheduling + wantFailures field.ErrorList + }{ + "good-scheduling": { + scheduling: testPodScheduling(goodName, goodNS, goodPodSchedulingSpec), + }, + "missing-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "name"), "name or generateName is required")}, + scheduling: testPodScheduling("", goodNS, goodPodSchedulingSpec), + }, + "bad-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + scheduling: testPodScheduling(badName, goodNS, goodPodSchedulingSpec), + }, + "missing-namespace": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "namespace"), "")}, + scheduling: testPodScheduling(goodName, "", goodPodSchedulingSpec), + }, + "generate-name": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.GenerateName = "pvc-" + return scheduling + }(), + }, + "uid": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.UID = "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d" + return scheduling + }(), + }, + "resource-version": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.ResourceVersion = "1" + return scheduling + }(), + }, + "generation": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Generation = 100 + return scheduling + }(), + }, + "creation-timestamp": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.CreationTimestamp = now + return scheduling + }(), + }, + "deletion-grace-period-seconds": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.DeletionGracePeriodSeconds = pointer.Int64(10) + return scheduling + }(), + }, + "owner-references": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "pod", + Name: "foo", + UID: "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d", + }, + } + return scheduling + }(), + }, + "finalizers": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Finalizers = []string{ + "example.com/foo", + } + return scheduling + }(), + }, + "managed-fields": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.ManagedFields = []metav1.ManagedFieldsEntry{ + { + FieldsType: "FieldsV1", + Operation: "Apply", + APIVersion: "apps/v1", + Manager: "foo", + }, + } + return scheduling + }(), + }, + "good-labels": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Labels = map[string]string{ + "apps.kubernetes.io/name": "test", + } + return scheduling + }(), + }, + "bad-labels": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "labels"), badValue, "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')")}, + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Labels = map[string]string{ + "hello-world": badValue, + } + return scheduling + }(), + }, + "good-annotations": { + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Annotations = map[string]string{ + "foo": "bar", + } + return scheduling + }(), + }, + "bad-annotations": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "annotations"), badName, "name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')")}, + scheduling: func() *resource.PodScheduling { + scheduling := testPodScheduling(goodName, goodNS, goodPodSchedulingSpec) + scheduling.Annotations = map[string]string{ + badName: "hello world", + } + return scheduling + }(), + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + errs := ValidatePodScheduling(scenario.scheduling) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidatePodSchedulingUpdate(t *testing.T) { + validScheduling := testPodScheduling("foo", "ns", resource.PodSchedulingSpec{}) + + scenarios := map[string]struct { + oldScheduling *resource.PodScheduling + update func(scheduling *resource.PodScheduling) *resource.PodScheduling + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { return scheduling }, + }, + "add-selected-node": { + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + scheduling.Spec.SelectedNode = "worker1" + return scheduling + }, + }, + "add-potential-nodes": { + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + for i := 0; i < resource.PodSchedulingNodeListMaxSize; i++ { + scheduling.Spec.PotentialNodes = append(scheduling.Spec.PotentialNodes, fmt.Sprintf("worker%d", i)) + } + return scheduling + }, + }, + "invalid-potential-nodes": { + wantFailures: field.ErrorList{field.TooLongMaxLength(field.NewPath("spec", "potentialNodes"), nil, resource.PodSchedulingNodeListMaxSize)}, + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + for i := 0; i < resource.PodSchedulingNodeListMaxSize+1; i++ { + scheduling.Spec.PotentialNodes = append(scheduling.Spec.PotentialNodes, fmt.Sprintf("worker%d", i)) + } + return scheduling + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldScheduling.ResourceVersion = "1" + errs := ValidatePodSchedulingUpdate(scenario.update(scenario.oldScheduling.DeepCopy()), scenario.oldScheduling) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidatePodSchedulingStatusUpdate(t *testing.T) { + validScheduling := testPodScheduling("foo", "ns", resource.PodSchedulingSpec{}) + + scenarios := map[string]struct { + oldScheduling *resource.PodScheduling + update func(scheduling *resource.PodScheduling) *resource.PodScheduling + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { return scheduling }, + }, + "add-claim-status": { + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + scheduling.Status.ResourceClaims = append(scheduling.Status.ResourceClaims, + resource.ResourceClaimSchedulingStatus{ + Name: "my-claim", + }, + ) + for i := 0; i < resource.PodSchedulingNodeListMaxSize; i++ { + scheduling.Status.ResourceClaims[0].UnsuitableNodes = append( + scheduling.Status.ResourceClaims[0].UnsuitableNodes, + fmt.Sprintf("worker%d", i), + ) + } + return scheduling + }, + }, + "invalid-duplicated-claim-status": { + wantFailures: field.ErrorList{field.Duplicate(field.NewPath("status", "claims").Index(1), "my-claim")}, + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + for i := 0; i < 2; i++ { + scheduling.Status.ResourceClaims = append(scheduling.Status.ResourceClaims, + resource.ResourceClaimSchedulingStatus{Name: "my-claim"}, + ) + } + return scheduling + }, + }, + "invalid-too-long-claim-status": { + wantFailures: field.ErrorList{field.TooLongMaxLength(field.NewPath("status", "claims").Index(0).Child("unsuitableNodes"), nil, resource.PodSchedulingNodeListMaxSize)}, + oldScheduling: validScheduling, + update: func(scheduling *resource.PodScheduling) *resource.PodScheduling { + scheduling.Status.ResourceClaims = append(scheduling.Status.ResourceClaims, + resource.ResourceClaimSchedulingStatus{ + Name: "my-claim", + }, + ) + for i := 0; i < resource.PodSchedulingNodeListMaxSize+1; i++ { + scheduling.Status.ResourceClaims[0].UnsuitableNodes = append( + scheduling.Status.ResourceClaims[0].UnsuitableNodes, + fmt.Sprintf("worker%d", i), + ) + } + return scheduling + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldScheduling.ResourceVersion = "1" + errs := ValidatePodSchedulingStatusUpdate(scenario.update(scenario.oldScheduling.DeepCopy()), scenario.oldScheduling) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} diff --git a/pkg/apis/resource/validation/validation_resourceclaim_test.go b/pkg/apis/resource/validation/validation_resourceclaim_test.go new file mode 100644 index 00000000000..4b3066fe3ca --- /dev/null +++ b/pkg/apis/resource/validation/validation_resourceclaim_test.go @@ -0,0 +1,629 @@ +/* +Copyright 2022 The Kubernetes 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 validation + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/utils/pointer" +) + +func testClaim(name, namespace string, spec resource.ResourceClaimSpec) *resource.ResourceClaim { + return &resource.ResourceClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: spec, + } +} + +func TestValidateClaim(t *testing.T) { + validMode := resource.AllocationModeImmediate + invalidMode := resource.AllocationMode("invalid") + goodName := "foo" + badName := "!@#$%^" + goodNS := "ns" + goodClaimSpec := resource.ResourceClaimSpec{ + ResourceClassName: goodName, + AllocationMode: validMode, + } + now := metav1.Now() + badValue := "spaces not allowed" + + scenarios := map[string]struct { + claim *resource.ResourceClaim + wantFailures field.ErrorList + }{ + "good-claim": { + claim: testClaim(goodName, goodNS, goodClaimSpec), + }, + "missing-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "name"), "name or generateName is required")}, + claim: testClaim("", goodNS, goodClaimSpec), + }, + "bad-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + claim: testClaim(badName, goodNS, goodClaimSpec), + }, + "missing-namespace": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "namespace"), "")}, + claim: testClaim(goodName, "", goodClaimSpec), + }, + "generate-name": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.GenerateName = "pvc-" + return claim + }(), + }, + "uid": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.UID = "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d" + return claim + }(), + }, + "resource-version": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.ResourceVersion = "1" + return claim + }(), + }, + "generation": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Generation = 100 + return claim + }(), + }, + "creation-timestamp": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.CreationTimestamp = now + return claim + }(), + }, + "deletion-grace-period-seconds": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.DeletionGracePeriodSeconds = pointer.Int64(10) + return claim + }(), + }, + "owner-references": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "pod", + Name: "foo", + UID: "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d", + }, + } + return claim + }(), + }, + "finalizers": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Finalizers = []string{ + "example.com/foo", + } + return claim + }(), + }, + "managed-fields": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.ManagedFields = []metav1.ManagedFieldsEntry{ + { + FieldsType: "FieldsV1", + Operation: "Apply", + APIVersion: "apps/v1", + Manager: "foo", + }, + } + return claim + }(), + }, + "good-labels": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Labels = map[string]string{ + "apps.kubernetes.io/name": "test", + } + return claim + }(), + }, + "bad-labels": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "labels"), badValue, "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')")}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Labels = map[string]string{ + "hello-world": badValue, + } + return claim + }(), + }, + "good-annotations": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Annotations = map[string]string{ + "foo": "bar", + } + return claim + }(), + }, + "bad-annotations": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "annotations"), badName, "name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')")}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Annotations = map[string]string{ + badName: "hello world", + } + return claim + }(), + }, + "bad-classname": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec", "resourceClassName"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Spec.ResourceClassName = badName + return claim + }(), + }, + "bad-mode": { + wantFailures: field.ErrorList{field.NotSupported(field.NewPath("spec", "allocationMode"), invalidMode, supportedAllocationModes.List())}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Spec.AllocationMode = invalidMode + return claim + }(), + }, + "good-parameters": { + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Kind: "foo", + Name: "bar", + } + return claim + }(), + }, + "missing-parameters-kind": { + wantFailures: field.ErrorList{field.Required(field.NewPath("spec", "parametersRef", "kind"), "")}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Name: "bar", + } + return claim + }(), + }, + "missing-parameters-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("spec", "parametersRef", "name"), "")}, + claim: func() *resource.ResourceClaim { + claim := testClaim(goodName, goodNS, goodClaimSpec) + claim.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Kind: "foo", + } + return claim + }(), + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + errs := ValidateClaim(scenario.claim) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidateClaimUpdate(t *testing.T) { + name := "valid" + parameters := &resource.ResourceClaimParametersReference{ + Kind: "foo", + Name: "bar", + } + validClaim := testClaim("foo", "ns", resource.ResourceClaimSpec{ + ResourceClassName: name, + AllocationMode: resource.AllocationModeImmediate, + ParametersRef: parameters, + }) + + scenarios := map[string]struct { + oldClaim *resource.ResourceClaim + update func(claim *resource.ResourceClaim) *resource.ResourceClaim + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { return claim }, + }, + "invalid-update-class": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimSpec { + spec := validClaim.Spec.DeepCopy() + spec.ResourceClassName += "2" + return *spec + }(), "field is immutable")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Spec.ResourceClassName += "2" + return claim + }, + }, + "invalid-update-remove-parameters": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimSpec { + spec := validClaim.Spec.DeepCopy() + spec.ParametersRef = nil + return *spec + }(), "field is immutable")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Spec.ParametersRef = nil + return claim + }, + }, + "invalid-update-mode": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimSpec { + spec := validClaim.Spec.DeepCopy() + spec.AllocationMode = resource.AllocationModeWaitForFirstConsumer + return *spec + }(), "field is immutable")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Spec.AllocationMode = resource.AllocationModeWaitForFirstConsumer + return claim + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldClaim.ResourceVersion = "1" + errs := ValidateClaimUpdate(scenario.update(scenario.oldClaim.DeepCopy()), scenario.oldClaim) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidateClaimStatusUpdate(t *testing.T) { + validClaim := testClaim("foo", "ns", resource.ResourceClaimSpec{ + ResourceClassName: "valid", + AllocationMode: resource.AllocationModeImmediate, + }) + + validAllocatedClaim := validClaim.DeepCopy() + validAllocatedClaim.Status = resource.ResourceClaimStatus{ + DriverName: "valid", + Allocation: &resource.AllocationResult{ + ResourceHandle: strings.Repeat(" ", resource.ResourceHandleMaxSize), + Shareable: true, + }, + } + + scenarios := map[string]struct { + oldClaim *resource.ResourceClaim + update func(claim *resource.ResourceClaim) *resource.ResourceClaim + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { return claim }, + }, + "add-driver": { + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DriverName = "valid" + return claim + }, + }, + "invalid-add-allocation": { + wantFailures: field.ErrorList{field.Required(field.NewPath("status", "driverName"), "must be specified when `allocation` is set")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + // DriverName must also get set here! + claim.Status.Allocation = &resource.AllocationResult{} + return claim + }, + }, + "valid-add-allocation": { + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DriverName = "valid" + claim.Status.Allocation = &resource.AllocationResult{ + ResourceHandle: strings.Repeat(" ", resource.ResourceHandleMaxSize), + } + return claim + }, + }, + "invalid-allocation-handle": { + wantFailures: field.ErrorList{field.TooLongMaxLength(field.NewPath("status", "allocation", "resourceHandle"), resource.ResourceHandleMaxSize+1, resource.ResourceHandleMaxSize)}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DriverName = "valid" + claim.Status.Allocation = &resource.AllocationResult{ + ResourceHandle: strings.Repeat(" ", resource.ResourceHandleMaxSize+1), + } + return claim + }, + }, + "invalid-node-selector": { + wantFailures: field.ErrorList{field.Required(field.NewPath("status", "allocation", "availableOnNodes", "nodeSelectorTerms"), "must have at least one node selector term")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DriverName = "valid" + claim.Status.Allocation = &resource.AllocationResult{ + AvailableOnNodes: &core.NodeSelector{ + // Must not be empty. + }, + } + return claim + }, + }, + "add-reservation": { + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + for i := 0; i < resource.ResourceClaimReservedForMaxSize; i++ { + claim.Status.ReservedFor = append(claim.Status.ReservedFor, + resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: fmt.Sprintf("foo-%d", i), + UID: "1", + }) + } + return claim + }, + }, + "add-reservation-and-allocation": { + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status = *validAllocatedClaim.Status.DeepCopy() + for i := 0; i < resource.ResourceClaimReservedForMaxSize; i++ { + claim.Status.ReservedFor = append(claim.Status.ReservedFor, + resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: fmt.Sprintf("foo-%d", i), + UID: "1", + }) + } + return claim + }, + }, + "invalid-reserved-for-too-large": { + wantFailures: field.ErrorList{field.TooLongMaxLength(field.NewPath("status", "reservedFor"), resource.ResourceClaimReservedForMaxSize+1, resource.ResourceClaimReservedForMaxSize)}, + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + for i := 0; i < resource.ResourceClaimReservedForMaxSize+1; i++ { + claim.Status.ReservedFor = append(claim.Status.ReservedFor, + resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: fmt.Sprintf("foo-%d", i), + UID: "1", + }) + } + return claim + }, + }, + "invalid-reserved-for-duplicate": { + wantFailures: field.ErrorList{field.Duplicate(field.NewPath("status", "reservedFor").Index(1), resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: "foo", + UID: "1", + })}, + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + for i := 0; i < 2; i++ { + claim.Status.ReservedFor = append(claim.Status.ReservedFor, + resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: "foo", + UID: "1", + }) + } + return claim + }, + }, + "invalid-reserved-for-not-shared": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "reservedFor"), "may not be reserved more than once")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + claim.Status.Allocation.Shareable = false + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + for i := 0; i < 2; i++ { + claim.Status.ReservedFor = append(claim.Status.ReservedFor, + resource.ResourceClaimConsumerReference{ + Resource: "pods", + Name: fmt.Sprintf("foo-%d", i), + UID: "1", + }) + } + return claim + }, + }, + "invalid-reserved-for-no-allocation": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "reservedFor"), "may not be specified when `allocated` is not set")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DriverName = "valid" + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + Name: "foo", + UID: "1", + }, + } + return claim + }, + }, + "invalid-reserved-for-no-resource": { + wantFailures: field.ErrorList{field.Required(field.NewPath("status", "reservedFor").Index(0).Child("resource"), "")}, + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Name: "foo", + UID: "1", + }, + } + return claim + }, + }, + "invalid-reserved-for-no-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("status", "reservedFor").Index(0).Child("name"), "")}, + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + UID: "1", + }, + } + return claim + }, + }, + "invalid-reserved-for-no-uid": { + wantFailures: field.ErrorList{field.Required(field.NewPath("status", "reservedFor").Index(0).Child("uid"), "")}, + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + Name: "foo", + }, + } + return claim + }, + }, + "invalid-reserved-deleted": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "reservedFor"), "new entries may not be added while `deallocationRequested` or `deletionTimestamp` are set")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + var deletionTimestamp metav1.Time + claim.DeletionTimestamp = &deletionTimestamp + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + Name: "foo", + UID: "1", + }, + } + return claim + }, + }, + "invalid-reserved-deallocation-requested": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "reservedFor"), "new entries may not be added while `deallocationRequested` or `deletionTimestamp` are set")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + claim.Status.DeallocationRequested = true + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + Name: "foo", + UID: "1", + }, + } + return claim + }, + }, + "add-deallocation-requested": { + oldClaim: validAllocatedClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DeallocationRequested = true + return claim + }, + }, + "invalid-deallocation-requested-removal": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "deallocationRequested"), "may not be cleared when `allocation` is set")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + claim.Status.DeallocationRequested = true + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DeallocationRequested = false + return claim + }, + }, + "invalid-deallocation-requested-in-use": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status", "deallocationRequested"), "deallocation cannot be requested while `reservedFor` is set")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + claim.Status.ReservedFor = []resource.ResourceClaimConsumerReference{ + { + Resource: "pods", + Name: "foo", + UID: "1", + }, + } + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DeallocationRequested = true + return claim + }, + }, + "invalid-deallocation-not-allocated": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status"), "`allocation` must be set when `deallocationRequested` is set")}, + oldClaim: validClaim, + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.DeallocationRequested = true + return claim + }, + }, + "invalid-allocation-removal-not-reset": { + wantFailures: field.ErrorList{field.Forbidden(field.NewPath("status"), "`allocation` must be set when `deallocationRequested` is set")}, + oldClaim: func() *resource.ResourceClaim { + claim := validAllocatedClaim.DeepCopy() + claim.Status.DeallocationRequested = true + return claim + }(), + update: func(claim *resource.ResourceClaim) *resource.ResourceClaim { + claim.Status.Allocation = nil + return claim + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldClaim.ResourceVersion = "1" + errs := ValidateClaimStatusUpdate(scenario.update(scenario.oldClaim.DeepCopy()), scenario.oldClaim) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} diff --git a/pkg/apis/resource/validation/validation_resourceclaimtemplate_test.go b/pkg/apis/resource/validation/validation_resourceclaimtemplate_test.go new file mode 100644 index 00000000000..23263f36114 --- /dev/null +++ b/pkg/apis/resource/validation/validation_resourceclaimtemplate_test.go @@ -0,0 +1,313 @@ +/* +Copyright 2022 The Kubernetes 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 validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/utils/pointer" +) + +func testClaimTemplate(name, namespace string, spec resource.ResourceClaimSpec) *resource.ResourceClaimTemplate { + return &resource.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: resource.ResourceClaimTemplateSpec{ + Spec: spec, + }, + } +} + +func TestValidateClaimTemplate(t *testing.T) { + validMode := resource.AllocationModeImmediate + invalidMode := resource.AllocationMode("invalid") + goodName := "foo" + badName := "!@#$%^" + goodNS := "ns" + goodClaimSpec := resource.ResourceClaimSpec{ + ResourceClassName: goodName, + AllocationMode: validMode, + } + now := metav1.Now() + badValue := "spaces not allowed" + + scenarios := map[string]struct { + template *resource.ResourceClaimTemplate + wantFailures field.ErrorList + }{ + "good-claim": { + template: testClaimTemplate(goodName, goodNS, goodClaimSpec), + }, + "missing-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "name"), "name or generateName is required")}, + template: testClaimTemplate("", goodNS, goodClaimSpec), + }, + "bad-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + template: testClaimTemplate(badName, goodNS, goodClaimSpec), + }, + "missing-namespace": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "namespace"), "")}, + template: testClaimTemplate(goodName, "", goodClaimSpec), + }, + "generate-name": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.GenerateName = "pvc-" + return template + }(), + }, + "uid": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.UID = "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d" + return template + }(), + }, + "resource-version": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.ResourceVersion = "1" + return template + }(), + }, + "generation": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Generation = 100 + return template + }(), + }, + "creation-timestamp": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.CreationTimestamp = now + return template + }(), + }, + "deletion-grace-period-seconds": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.DeletionGracePeriodSeconds = pointer.Int64(10) + return template + }(), + }, + "owner-references": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "pod", + Name: "foo", + UID: "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d", + }, + } + return template + }(), + }, + "finalizers": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Finalizers = []string{ + "example.com/foo", + } + return template + }(), + }, + "managed-fields": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.ManagedFields = []metav1.ManagedFieldsEntry{ + { + FieldsType: "FieldsV1", + Operation: "Apply", + APIVersion: "apps/v1", + Manager: "foo", + }, + } + return template + }(), + }, + "good-labels": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Labels = map[string]string{ + "apps.kubernetes.io/name": "test", + } + return template + }(), + }, + "bad-labels": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "labels"), badValue, "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')")}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Labels = map[string]string{ + "hello-world": badValue, + } + return template + }(), + }, + "good-annotations": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Annotations = map[string]string{ + "foo": "bar", + } + return template + }(), + }, + "bad-annotations": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "annotations"), badName, "name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')")}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Annotations = map[string]string{ + badName: "hello world", + } + return template + }(), + }, + "bad-classname": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec", "spec", "resourceClassName"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Spec.Spec.ResourceClassName = badName + return template + }(), + }, + "bad-mode": { + wantFailures: field.ErrorList{field.NotSupported(field.NewPath("spec", "spec", "allocationMode"), invalidMode, supportedAllocationModes.List())}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Spec.Spec.AllocationMode = invalidMode + return template + }(), + }, + "good-parameters": { + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Spec.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Kind: "foo", + Name: "bar", + } + return template + }(), + }, + "missing-parameters-kind": { + wantFailures: field.ErrorList{field.Required(field.NewPath("spec", "spec", "parametersRef", "kind"), "")}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Spec.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Name: "bar", + } + return template + }(), + }, + "missing-parameters-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("spec", "spec", "parametersRef", "name"), "")}, + template: func() *resource.ResourceClaimTemplate { + template := testClaimTemplate(goodName, goodNS, goodClaimSpec) + template.Spec.Spec.ParametersRef = &resource.ResourceClaimParametersReference{ + Kind: "foo", + } + return template + }(), + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + errs := ValidateClaimTemplate(scenario.template) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidateClaimTemplateUpdate(t *testing.T) { + name := "valid" + parameters := &resource.ResourceClaimParametersReference{ + Kind: "foo", + Name: "bar", + } + validClaimTemplate := testClaimTemplate("foo", "ns", resource.ResourceClaimSpec{ + ResourceClassName: name, + AllocationMode: resource.AllocationModeImmediate, + ParametersRef: parameters, + }) + + scenarios := map[string]struct { + oldClaimTemplate *resource.ResourceClaimTemplate + update func(claim *resource.ResourceClaimTemplate) *resource.ResourceClaimTemplate + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldClaimTemplate: validClaimTemplate, + update: func(claim *resource.ResourceClaimTemplate) *resource.ResourceClaimTemplate { return claim }, + }, + "invalid-update-class": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimTemplateSpec { + spec := validClaimTemplate.Spec.DeepCopy() + spec.Spec.ResourceClassName += "2" + return *spec + }(), "field is immutable")}, + oldClaimTemplate: validClaimTemplate, + update: func(template *resource.ResourceClaimTemplate) *resource.ResourceClaimTemplate { + template.Spec.Spec.ResourceClassName += "2" + return template + }, + }, + "invalid-update-remove-parameters": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimTemplateSpec { + spec := validClaimTemplate.Spec.DeepCopy() + spec.Spec.ParametersRef = nil + return *spec + }(), "field is immutable")}, + oldClaimTemplate: validClaimTemplate, + update: func(template *resource.ResourceClaimTemplate) *resource.ResourceClaimTemplate { + template.Spec.Spec.ParametersRef = nil + return template + }, + }, + "invalid-update-mode": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("spec"), func() resource.ResourceClaimTemplateSpec { + spec := validClaimTemplate.Spec.DeepCopy() + spec.Spec.AllocationMode = resource.AllocationModeWaitForFirstConsumer + return *spec + }(), "field is immutable")}, + oldClaimTemplate: validClaimTemplate, + update: func(template *resource.ResourceClaimTemplate) *resource.ResourceClaimTemplate { + template.Spec.Spec.AllocationMode = resource.AllocationModeWaitForFirstConsumer + return template + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldClaimTemplate.ResourceVersion = "1" + errs := ValidateClaimTemplateUpdate(scenario.update(scenario.oldClaimTemplate.DeepCopy()), scenario.oldClaimTemplate) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} diff --git a/pkg/apis/resource/validation/validation_resourceclass_test.go b/pkg/apis/resource/validation/validation_resourceclass_test.go new file mode 100644 index 00000000000..c84429f9406 --- /dev/null +++ b/pkg/apis/resource/validation/validation_resourceclass_test.go @@ -0,0 +1,282 @@ +/* +Copyright 2022 The Kubernetes 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 validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/utils/pointer" +) + +func testClass(name, driverName string) *resource.ResourceClass { + return &resource.ResourceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + DriverName: driverName, + } +} + +func TestValidateClass(t *testing.T) { + goodName := "foo" + now := metav1.Now() + goodParameters := resource.ResourceClassParametersReference{ + Name: "valid", + Namespace: "valid", + Kind: "foo", + } + badName := "!@#$%^" + badValue := "spaces not allowed" + + scenarios := map[string]struct { + class *resource.ResourceClass + wantFailures field.ErrorList + }{ + "good-class": { + class: testClass(goodName, goodName), + }, + "good-long-driver-name": { + class: testClass(goodName, "acme.example.com"), + }, + "missing-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("metadata", "name"), "name or generateName is required")}, + class: testClass("", goodName), + }, + "bad-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "name"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + class: testClass(badName, goodName), + }, + "generate-name": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.GenerateName = "pvc-" + return class + }(), + }, + "uid": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.UID = "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d" + return class + }(), + }, + "resource-version": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ResourceVersion = "1" + return class + }(), + }, + "generation": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Generation = 100 + return class + }(), + }, + "creation-timestamp": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.CreationTimestamp = now + return class + }(), + }, + "deletion-grace-period-seconds": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.DeletionGracePeriodSeconds = pointer.Int64(10) + return class + }(), + }, + "owner-references": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "pod", + Name: "foo", + UID: "ac051fac-2ead-46d9-b8b4-4e0fbeb7455d", + }, + } + return class + }(), + }, + "finalizers": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Finalizers = []string{ + "example.com/foo", + } + return class + }(), + }, + "managed-fields": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ManagedFields = []metav1.ManagedFieldsEntry{ + { + FieldsType: "FieldsV1", + Operation: "Apply", + APIVersion: "apps/v1", + Manager: "foo", + }, + } + return class + }(), + }, + "good-labels": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Labels = map[string]string{ + "apps.kubernetes.io/name": "test", + } + return class + }(), + }, + "bad-labels": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "labels"), badValue, "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Labels = map[string]string{ + "hello-world": badValue, + } + return class + }(), + }, + "good-annotations": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Annotations = map[string]string{ + "foo": "bar", + } + return class + }(), + }, + "bad-annotations": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("metadata", "annotations"), badName, "name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.Annotations = map[string]string{ + badName: "hello world", + } + return class + }(), + }, + "missing-driver-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("driverName"), ""), + field.Invalid(field.NewPath("driverName"), "", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')"), + }, + class: testClass(goodName, ""), + }, + "invalid-driver-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("driverName"), badName, "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + class: testClass(goodName, badName), + }, + "invalid-qualified-driver-name": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("driverName"), goodName+"/path", "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')")}, + class: testClass(goodName, goodName+"/path"), + }, + "good-parameters": { + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ParametersRef = goodParameters.DeepCopy() + return class + }(), + }, + "missing-parameters-name": { + wantFailures: field.ErrorList{field.Required(field.NewPath("parametersRef", "name"), "")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ParametersRef = goodParameters.DeepCopy() + class.ParametersRef.Name = "" + return class + }(), + }, + "bad-parameters-namespace": { + wantFailures: field.ErrorList{field.Invalid(field.NewPath("parametersRef", "namespace"), badName, "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character (e.g. 'my-name', or '123-abc', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?')")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ParametersRef = goodParameters.DeepCopy() + class.ParametersRef.Namespace = badName + return class + }(), + }, + "missing-parameters-kind": { + wantFailures: field.ErrorList{field.Required(field.NewPath("parametersRef", "kind"), "")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.ParametersRef = goodParameters.DeepCopy() + class.ParametersRef.Kind = "" + return class + }(), + }, + "invalid-node-selector": { + wantFailures: field.ErrorList{field.Required(field.NewPath("suitableNodes", "nodeSelectorTerms"), "must have at least one node selector term")}, + class: func() *resource.ResourceClass { + class := testClass(goodName, goodName) + class.SuitableNodes = &core.NodeSelector{ + // Must not be empty. + } + return class + }(), + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + errs := ValidateClass(scenario.class) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} + +func TestValidateClassUpdate(t *testing.T) { + validClass := testClass("foo", "valid") + + scenarios := map[string]struct { + oldClass *resource.ResourceClass + update func(class *resource.ResourceClass) *resource.ResourceClass + wantFailures field.ErrorList + }{ + "valid-no-op-update": { + oldClass: validClass, + update: func(class *resource.ResourceClass) *resource.ResourceClass { return class }, + }, + "update-driver": { + oldClass: validClass, + update: func(class *resource.ResourceClass) *resource.ResourceClass { + class.DriverName += "2" + return class + }, + }, + } + + for name, scenario := range scenarios { + t.Run(name, func(t *testing.T) { + scenario.oldClass.ResourceVersion = "1" + errs := ValidateClassUpdate(scenario.update(scenario.oldClass.DeepCopy()), scenario.oldClass) + assert.Equal(t, scenario.wantFailures, errs) + }) + } +} diff --git a/pkg/controlplane/import_known_versions.go b/pkg/controlplane/import_known_versions.go index b4b275ce82f..6c39ea577ff 100644 --- a/pkg/controlplane/import_known_versions.go +++ b/pkg/controlplane/import_known_versions.go @@ -38,6 +38,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" + _ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/scheduling/install" _ "k8s.io/kubernetes/pkg/apis/storage/install" ) diff --git a/pkg/controlplane/instance.go b/pkg/controlplane/instance.go index da41a5c3565..8936ceea41e 100644 --- a/pkg/controlplane/instance.go +++ b/pkg/controlplane/instance.go @@ -54,6 +54,7 @@ import ( policyapiv1 "k8s.io/api/policy/v1" policyapiv1beta1 "k8s.io/api/policy/v1beta1" rbacv1 "k8s.io/api/rbac/v1" + resourcev1alpha1 "k8s.io/api/resource/v1alpha1" schedulingapiv1 "k8s.io/api/scheduling/v1" storageapiv1 "k8s.io/api/storage/v1" storageapiv1alpha1 "k8s.io/api/storage/v1alpha1" @@ -108,6 +109,7 @@ import ( noderest "k8s.io/kubernetes/pkg/registry/node/rest" policyrest "k8s.io/kubernetes/pkg/registry/policy/rest" rbacrest "k8s.io/kubernetes/pkg/registry/rbac/rest" + resourcerest "k8s.io/kubernetes/pkg/registry/resource/rest" schedulingrest "k8s.io/kubernetes/pkg/registry/scheduling/rest" storagerest "k8s.io/kubernetes/pkg/registry/storage/rest" ) @@ -435,6 +437,7 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) appsrest.StorageProvider{}, admissionregistrationrest.RESTStorageProvider{Authorizer: c.GenericConfig.Authorization.Authorizer, DiscoveryClient: discoveryClientForAdmissionRegistration}, eventsrest.RESTStorageProvider{TTL: c.ExtraConfig.EventTTL}, + resourcerest.RESTStorageProvider{}, } if err := m.InstallAPIs(c.ExtraConfig.APIResourceConfigSource, c.GenericConfig.RESTOptionsGetter, restStorageProviders...); err != nil { return nil, err @@ -704,6 +707,7 @@ var ( admissionregistrationv1alpha1.SchemeGroupVersion, apiserverinternalv1alpha1.SchemeGroupVersion, authenticationv1alpha1.SchemeGroupVersion, + resourcev1alpha1.SchemeGroupVersion, networkingapiv1alpha1.SchemeGroupVersion, storageapiv1alpha1.SchemeGroupVersion, flowcontrolv1alpha1.SchemeGroupVersion, diff --git a/pkg/kubectl/.import-restrictions b/pkg/kubectl/.import-restrictions index 8eb1c93f72a..064a6497011 100644 --- a/pkg/kubectl/.import-restrictions +++ b/pkg/kubectl/.import-restrictions @@ -78,6 +78,9 @@ rules: - k8s.io/kubernetes/pkg/apis/rbac/v1 - k8s.io/kubernetes/pkg/apis/rbac/v1alpha1 - k8s.io/kubernetes/pkg/apis/rbac/v1beta1 + - k8s.io/kubernetes/pkg/apis/resource + - k8s.io/kubernetes/pkg/apis/resource/install + - k8s.io/kubernetes/pkg/apis/resource/v1alpha1 - k8s.io/kubernetes/pkg/apis/scheduling - k8s.io/kubernetes/pkg/apis/scheduling/install - k8s.io/kubernetes/pkg/apis/scheduling/v1alpha1 diff --git a/pkg/kubectl/cmd/convert/import_known_versions.go b/pkg/kubectl/cmd/convert/import_known_versions.go index 6c7ed499f5c..6e261624f55 100644 --- a/pkg/kubectl/cmd/convert/import_known_versions.go +++ b/pkg/kubectl/cmd/convert/import_known_versions.go @@ -38,6 +38,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" + _ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/scheduling/install" _ "k8s.io/kubernetes/pkg/apis/storage/install" diff --git a/pkg/printers/internalversion/import_known_versions.go b/pkg/printers/internalversion/import_known_versions.go index 027e89fe1aa..c9c2674475e 100644 --- a/pkg/printers/internalversion/import_known_versions.go +++ b/pkg/printers/internalversion/import_known_versions.go @@ -32,6 +32,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/extensions/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" + _ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/scheduling/install" _ "k8s.io/kubernetes/pkg/apis/storage/install" ) diff --git a/pkg/registry/resource/OWNERS b/pkg/registry/resource/OWNERS new file mode 100644 index 00000000000..98397072ae5 --- /dev/null +++ b/pkg/registry/resource/OWNERS @@ -0,0 +1,6 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +reviewers: + - bart0sh + - klueska + - pohly diff --git a/pkg/registry/resource/podscheduling/storage/storage.go b/pkg/registry/resource/podscheduling/storage/storage.go new file mode 100644 index 00000000000..b977f283b85 --- /dev/null +++ b/pkg/registry/resource/podscheduling/storage/storage.go @@ -0,0 +1,100 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/resource/podscheduling" + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" +) + +// REST implements a RESTStorage for PodSchedulings. +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against PodSchedulings. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) { + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &resource.PodScheduling{} }, + NewListFunc: func() runtime.Object { return &resource.PodSchedulingList{} }, + PredicateFunc: podscheduling.Match, + DefaultQualifiedResource: resource.Resource("podschedulings"), + + CreateStrategy: podscheduling.Strategy, + UpdateStrategy: podscheduling.Strategy, + DeleteStrategy: podscheduling.Strategy, + ReturnDeletedObject: true, + ResetFieldsStrategy: podscheduling.Strategy, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: podscheduling.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, nil, err + } + + statusStore := *store + statusStore.UpdateStrategy = podscheduling.StatusStrategy + statusStore.ResetFieldsStrategy = podscheduling.StatusStrategy + + rest := &REST{store} + + return rest, &StatusREST{store: &statusStore}, nil +} + +// StatusREST implements the REST endpoint for changing the status of a PodScheduling. +type StatusREST struct { + store *genericregistry.Store +} + +// New creates a new PodScheduling object. +func (r *StatusREST) New() runtime.Object { + return &resource.PodScheduling{} +} + +func (r *StatusREST) Destroy() { + // Given that underlying store is shared with REST, + // we don't destroy it here explicitly. +} + +// Get retrieves the object from the storage. It is required to support Patch. +func (r *StatusREST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return r.store.Get(ctx, name, options) +} + +// Update alters the status subset of an object. +func (r *StatusREST) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + // We are explicitly setting forceAllowCreate to false in the call to the underlying storage because + // subresources should never allow create on update. + return r.store.Update(ctx, name, objInfo, createValidation, updateValidation, false, options) +} + +// GetResetFields implements rest.ResetFieldsStrategy +func (r *StatusREST) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + return r.store.GetResetFields() +} diff --git a/pkg/registry/resource/podscheduling/storage/storage_test.go b/pkg/registry/resource/podscheduling/storage/storage_test.go new file mode 100644 index 00000000000..b400a2abcbc --- /dev/null +++ b/pkg/registry/resource/podscheduling/storage/storage_test.go @@ -0,0 +1,184 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "testing" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/diff" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/generic" + genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" + "k8s.io/apiserver/pkg/registry/rest" + etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" + "k8s.io/kubernetes/pkg/apis/resource" + _ "k8s.io/kubernetes/pkg/apis/resource/install" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "podschedulings", + } + podSchedulingStorage, statusStorage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return podSchedulingStorage, statusStorage, server +} + +func validNewPodScheduling(name, ns string) *resource.PodScheduling { + scheduling := &resource.PodScheduling{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: resource.PodSchedulingSpec{ + SelectedNode: "worker", + }, + Status: resource.PodSchedulingStatus{}, + } + return scheduling +} + +func TestCreate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + scheduling := validNewPodScheduling("foo", metav1.NamespaceDefault) + scheduling.ObjectMeta = metav1.ObjectMeta{} + test.TestCreate( + // valid + scheduling, + // invalid + &resource.PodScheduling{ + ObjectMeta: metav1.ObjectMeta{Name: "*BadName!"}, + }, + ) +} + +func TestUpdate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestUpdate( + // valid + validNewPodScheduling("foo", metav1.NamespaceDefault), + // updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.PodScheduling) + if object.Labels == nil { + object.Labels = map[string]string{} + } + object.Labels["foo"] = "bar" + return object + }, + ) +} + +func TestDelete(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ReturnDeletedObject() + test.TestDelete(validNewPodScheduling("foo", metav1.NamespaceDefault)) +} + +func TestGet(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestGet(validNewPodScheduling("foo", metav1.NamespaceDefault)) +} + +func TestList(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestList(validNewPodScheduling("foo", metav1.NamespaceDefault)) +} + +func TestWatch(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestWatch( + validNewPodScheduling("foo", metav1.NamespaceDefault), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"foo": "bar"}, + }, + // matching fields + []fields.Set{ + {"metadata.name": "foo"}, + }, + // not matching fields + []fields.Set{ + {"metadata.name": "bar"}, + }, + ) +} + +func TestUpdateStatus(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + ctx := genericapirequest.NewDefaultContext() + + key, _ := storage.KeyFunc(ctx, "foo") + schedulingStart := validNewPodScheduling("foo", metav1.NamespaceDefault) + err := storage.Storage.Create(ctx, key, schedulingStart, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + scheduling := schedulingStart.DeepCopy() + scheduling.Status.ResourceClaims = append(scheduling.Status.ResourceClaims, + resource.ResourceClaimSchedulingStatus{ + Name: "my-claim", + }, + ) + _, _, err = statusStorage.Update(ctx, scheduling.Name, rest.DefaultUpdatedObjectInfo(scheduling), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + obj, err := storage.Get(ctx, "foo", &metav1.GetOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + schedulingOut := obj.(*resource.PodScheduling) + // only compare relevant changes b/c of difference in metadata + if !apiequality.Semantic.DeepEqual(scheduling.Status, schedulingOut.Status) { + t.Errorf("unexpected object: %s", diff.ObjectDiff(scheduling.Status, schedulingOut.Status)) + } +} diff --git a/pkg/registry/resource/podscheduling/strategy.go b/pkg/registry/resource/podscheduling/strategy.go new file mode 100644 index 00000000000..99421213f4c --- /dev/null +++ b/pkg/registry/resource/podscheduling/strategy.go @@ -0,0 +1,163 @@ +/* +Copyright 2022 The Kubernetes 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 podscheduling + +import ( + "context" + "errors" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/storage" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/apis/resource/validation" + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" +) + +// podSchedulingStrategy implements behavior for PodScheduling objects +type podSchedulingStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +// Strategy is the default logic that applies when creating and updating +// ResourceClaim objects via the REST API. +var Strategy = podSchedulingStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} + +func (podSchedulingStrategy) NamespaceScoped() bool { + return true +} + +// GetResetFields returns the set of fields that get reset by the strategy and +// should not be modified by the user. For a new PodScheduling that is the +// status. +func (podSchedulingStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "resource.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("status"), + ), + } + + return fields +} + +func (podSchedulingStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { + scheduling := obj.(*resource.PodScheduling) + // Status must not be set by user on create. + scheduling.Status = resource.PodSchedulingStatus{} +} + +func (podSchedulingStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + scheduling := obj.(*resource.PodScheduling) + return validation.ValidatePodScheduling(scheduling) +} + +func (podSchedulingStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (podSchedulingStrategy) Canonicalize(obj runtime.Object) { +} + +func (podSchedulingStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (podSchedulingStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newScheduling := obj.(*resource.PodScheduling) + oldScheduling := old.(*resource.PodScheduling) + newScheduling.Status = oldScheduling.Status +} + +func (podSchedulingStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + newScheduling := obj.(*resource.PodScheduling) + oldScheduling := old.(*resource.PodScheduling) + errorList := validation.ValidatePodScheduling(newScheduling) + return append(errorList, validation.ValidatePodSchedulingUpdate(newScheduling, oldScheduling)...) +} + +func (podSchedulingStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (podSchedulingStrategy) AllowUnconditionalUpdate() bool { + return true +} + +type podSchedulingStatusStrategy struct { + podSchedulingStrategy +} + +var StatusStrategy = podSchedulingStatusStrategy{Strategy} + +// GetResetFields returns the set of fields that get reset by the strategy and +// should not be modified by the user. For a status update that is the spec. +func (podSchedulingStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "resource.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("spec"), + ), + } + + return fields +} + +func (podSchedulingStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newScheduling := obj.(*resource.PodScheduling) + oldScheduling := old.(*resource.PodScheduling) + newScheduling.Spec = oldScheduling.Spec +} + +func (podSchedulingStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + newScheduling := obj.(*resource.PodScheduling) + oldScheduling := old.(*resource.PodScheduling) + return validation.ValidatePodSchedulingStatusUpdate(newScheduling, oldScheduling) +} + +// WarningsOnUpdate returns warnings for the given update. +func (podSchedulingStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +// Match returns a generic matcher for a given label and field selector. +func Match(label labels.Selector, field fields.Selector) storage.SelectionPredicate { + return storage.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} + +// GetAttrs returns labels and fields of a given object for filtering purposes. +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + scheduling, ok := obj.(*resource.PodScheduling) + if !ok { + return nil, nil, errors.New("not a PodScheduling") + } + return labels.Set(scheduling.Labels), toSelectableFields(scheduling), nil +} + +// toSelectableFields returns a field set that represents the object +func toSelectableFields(scheduling *resource.PodScheduling) fields.Set { + fields := generic.ObjectMetaFieldsSet(&scheduling.ObjectMeta, true) + return fields +} diff --git a/pkg/registry/resource/podscheduling/strategy_test.go b/pkg/registry/resource/podscheduling/strategy_test.go new file mode 100644 index 00000000000..9ad878c68ac --- /dev/null +++ b/pkg/registry/resource/podscheduling/strategy_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2022 The Kubernetes 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 podscheduling + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/resource" +) + +var podScheduling = &resource.PodScheduling{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-pod", + Namespace: "default", + }, + Spec: resource.PodSchedulingSpec{ + SelectedNode: "worker", + }, +} + +func TestPodSchedulingStrategy(t *testing.T) { + if !Strategy.NamespaceScoped() { + t.Errorf("PodScheduling must be namespace scoped") + } + if Strategy.AllowCreateOnUpdate() { + t.Errorf("PodScheduling should not allow create on update") + } +} + +func TestPodSchedulingStrategyCreate(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + podScheduling := podScheduling.DeepCopy() + + Strategy.PrepareForCreate(ctx, podScheduling) + errs := Strategy.Validate(ctx, podScheduling) + if len(errs) != 0 { + t.Errorf("unexpected error validating for create %v", errs) + } +} + +func TestPodSchedulingStrategyUpdate(t *testing.T) { + t.Run("no-changes-okay", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + podScheduling := podScheduling.DeepCopy() + newPodScheduling := podScheduling.DeepCopy() + newPodScheduling.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newPodScheduling, podScheduling) + errs := Strategy.ValidateUpdate(ctx, newPodScheduling, podScheduling) + if len(errs) != 0 { + t.Errorf("unexpected validation errors: %v", errs) + } + }) + + t.Run("name-change-not-allowed", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + podScheduling := podScheduling.DeepCopy() + newPodScheduling := podScheduling.DeepCopy() + newPodScheduling.Name = "valid-claim-2" + newPodScheduling.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newPodScheduling, podScheduling) + errs := Strategy.ValidateUpdate(ctx, newPodScheduling, podScheduling) + if len(errs) == 0 { + t.Errorf("expected a validation error") + } + }) +} diff --git a/pkg/registry/resource/resourceclaim/storage/storage.go b/pkg/registry/resource/resourceclaim/storage/storage.go new file mode 100644 index 00000000000..959cb680bfe --- /dev/null +++ b/pkg/registry/resource/resourceclaim/storage/storage.go @@ -0,0 +1,100 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/resource/resourceclaim" + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" +) + +// REST implements a RESTStorage for ResourceClaims. +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against ResourceClaims. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) { + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &resource.ResourceClaim{} }, + NewListFunc: func() runtime.Object { return &resource.ResourceClaimList{} }, + PredicateFunc: resourceclaim.Match, + DefaultQualifiedResource: resource.Resource("resourceclaims"), + + CreateStrategy: resourceclaim.Strategy, + UpdateStrategy: resourceclaim.Strategy, + DeleteStrategy: resourceclaim.Strategy, + ReturnDeletedObject: true, + ResetFieldsStrategy: resourceclaim.Strategy, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: resourceclaim.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, nil, err + } + + statusStore := *store + statusStore.UpdateStrategy = resourceclaim.StatusStrategy + statusStore.ResetFieldsStrategy = resourceclaim.StatusStrategy + + rest := &REST{store} + + return rest, &StatusREST{store: &statusStore}, nil +} + +// StatusREST implements the REST endpoint for changing the status of a ResourceClaim. +type StatusREST struct { + store *genericregistry.Store +} + +// New creates a new ResourceClaim object. +func (r *StatusREST) New() runtime.Object { + return &resource.ResourceClaim{} +} + +func (r *StatusREST) Destroy() { + // Given that underlying store is shared with REST, + // we don't destroy it here explicitly. +} + +// Get retrieves the object from the storage. It is required to support Patch. +func (r *StatusREST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return r.store.Get(ctx, name, options) +} + +// Update alters the status subset of an object. +func (r *StatusREST) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + // We are explicitly setting forceAllowCreate to false in the call to the underlying storage because + // subresources should never allow create on update. + return r.store.Update(ctx, name, objInfo, createValidation, updateValidation, false, options) +} + +// GetResetFields implements rest.ResetFieldsStrategy +func (r *StatusREST) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + return r.store.GetResetFields() +} diff --git a/pkg/registry/resource/resourceclaim/storage/storage_test.go b/pkg/registry/resource/resourceclaim/storage/storage_test.go new file mode 100644 index 00000000000..cc2e1594a45 --- /dev/null +++ b/pkg/registry/resource/resourceclaim/storage/storage_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "testing" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/diff" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/generic" + genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" + "k8s.io/apiserver/pkg/registry/rest" + etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" + "k8s.io/kubernetes/pkg/apis/resource" + _ "k8s.io/kubernetes/pkg/apis/resource/install" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "resourceclaims", + } + resourceClaimStorage, statusStorage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return resourceClaimStorage, statusStorage, server +} + +func validNewClaim(name, ns string) *resource.ResourceClaim { + claim := &resource.ResourceClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: resource.ResourceClaimSpec{ + ResourceClassName: "example", + AllocationMode: resource.AllocationModeImmediate, + }, + Status: resource.ResourceClaimStatus{}, + } + return claim +} + +func TestCreate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + claim := validNewClaim("foo", metav1.NamespaceDefault) + claim.ObjectMeta = metav1.ObjectMeta{} + test.TestCreate( + // valid + claim, + // invalid + &resource.ResourceClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "*BadName!"}, + }, + ) +} + +func TestUpdate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestUpdate( + // valid + validNewClaim("foo", metav1.NamespaceDefault), + // updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.ResourceClaim) + if object.Labels == nil { + object.Labels = map[string]string{} + } + object.Labels["foo"] = "bar" + return object + }, + ) +} + +func TestDelete(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ReturnDeletedObject() + test.TestDelete(validNewClaim("foo", metav1.NamespaceDefault)) +} + +func TestGet(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestGet(validNewClaim("foo", metav1.NamespaceDefault)) +} + +func TestList(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestList(validNewClaim("foo", metav1.NamespaceDefault)) +} + +func TestWatch(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestWatch( + validNewClaim("foo", metav1.NamespaceDefault), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"foo": "bar"}, + }, + // matching fields + []fields.Set{ + {"metadata.name": "foo"}, + }, + // not matching fields + []fields.Set{ + {"metadata.name": "bar"}, + }, + ) +} + +func TestUpdateStatus(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + ctx := genericapirequest.NewDefaultContext() + + key, _ := storage.KeyFunc(ctx, "foo") + claimStart := validNewClaim("foo", metav1.NamespaceDefault) + err := storage.Storage.Create(ctx, key, claimStart, nil, 0, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + claim := claimStart.DeepCopy() + claim.Status.DriverName = "some-driver.example.com" + claim.Status.Allocation = &resource.AllocationResult{} + _, _, err = statusStorage.Update(ctx, claim.Name, rest.DefaultUpdatedObjectInfo(claim), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + obj, err := storage.Get(ctx, "foo", &metav1.GetOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + claimOut := obj.(*resource.ResourceClaim) + // only compare relevant changes b/c of difference in metadata + if !apiequality.Semantic.DeepEqual(claim.Status, claimOut.Status) { + t.Errorf("unexpected object: %s", diff.ObjectDiff(claim.Status, claimOut.Status)) + } +} diff --git a/pkg/registry/resource/resourceclaim/strategy.go b/pkg/registry/resource/resourceclaim/strategy.go new file mode 100644 index 00000000000..8b71300de85 --- /dev/null +++ b/pkg/registry/resource/resourceclaim/strategy.go @@ -0,0 +1,163 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclaim + +import ( + "context" + "errors" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/storage" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/apis/resource/validation" + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" +) + +// resourceclaimStrategy implements behavior for ResourceClaim objects +type resourceclaimStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +// Strategy is the default logic that applies when creating and updating +// ResourceClaim objects via the REST API. +var Strategy = resourceclaimStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} + +func (resourceclaimStrategy) NamespaceScoped() bool { + return true +} + +// GetResetFields returns the set of fields that get reset by the strategy and +// should not be modified by the user. For a new ResourceClaim that is the +// status. +func (resourceclaimStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "resource.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("status"), + ), + } + + return fields +} + +func (resourceclaimStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { + claim := obj.(*resource.ResourceClaim) + // Status must not be set by user on create. + claim.Status = resource.ResourceClaimStatus{} +} + +func (resourceclaimStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + claim := obj.(*resource.ResourceClaim) + return validation.ValidateClaim(claim) +} + +func (resourceclaimStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (resourceclaimStrategy) Canonicalize(obj runtime.Object) { +} + +func (resourceclaimStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (resourceclaimStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newClaim := obj.(*resource.ResourceClaim) + oldClaim := old.(*resource.ResourceClaim) + newClaim.Status = oldClaim.Status +} + +func (resourceclaimStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + newClaim := obj.(*resource.ResourceClaim) + oldClaim := old.(*resource.ResourceClaim) + errorList := validation.ValidateClaim(newClaim) + return append(errorList, validation.ValidateClaimUpdate(newClaim, oldClaim)...) +} + +func (resourceclaimStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (resourceclaimStrategy) AllowUnconditionalUpdate() bool { + return true +} + +type resourceclaimStatusStrategy struct { + resourceclaimStrategy +} + +var StatusStrategy = resourceclaimStatusStrategy{Strategy} + +// GetResetFields returns the set of fields that get reset by the strategy and +// should not be modified by the user. For a status update that is the spec. +func (resourceclaimStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "resource.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("spec"), + ), + } + + return fields +} + +func (resourceclaimStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newClaim := obj.(*resource.ResourceClaim) + oldClaim := old.(*resource.ResourceClaim) + newClaim.Spec = oldClaim.Spec +} + +func (resourceclaimStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + newClaim := obj.(*resource.ResourceClaim) + oldClaim := old.(*resource.ResourceClaim) + return validation.ValidateClaimStatusUpdate(newClaim, oldClaim) +} + +// WarningsOnUpdate returns warnings for the given update. +func (resourceclaimStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +// Match returns a generic matcher for a given label and field selector. +func Match(label labels.Selector, field fields.Selector) storage.SelectionPredicate { + return storage.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} + +// GetAttrs returns labels and fields of a given object for filtering purposes. +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + claim, ok := obj.(*resource.ResourceClaim) + if !ok { + return nil, nil, errors.New("not a resourceclaim") + } + return labels.Set(claim.Labels), toSelectableFields(claim), nil +} + +// toSelectableFields returns a field set that represents the object +func toSelectableFields(claim *resource.ResourceClaim) fields.Set { + fields := generic.ObjectMetaFieldsSet(&claim.ObjectMeta, true) + return fields +} diff --git a/pkg/registry/resource/resourceclaim/strategy_test.go b/pkg/registry/resource/resourceclaim/strategy_test.go new file mode 100644 index 00000000000..d4091893b5b --- /dev/null +++ b/pkg/registry/resource/resourceclaim/strategy_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclaim + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/resource" +) + +var resourceClaim = &resource.ResourceClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-claim", + Namespace: "default", + }, + Spec: resource.ResourceClaimSpec{ + ResourceClassName: "valid-class", + AllocationMode: resource.AllocationModeImmediate, + }, +} + +func TestClaimStrategy(t *testing.T) { + if !Strategy.NamespaceScoped() { + t.Errorf("ResourceClaim must be namespace scoped") + } + if Strategy.AllowCreateOnUpdate() { + t.Errorf("ResourceClaim should not allow create on update") + } +} + +func TestClaimStrategyCreate(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaim := resourceClaim.DeepCopy() + + Strategy.PrepareForCreate(ctx, resourceClaim) + errs := Strategy.Validate(ctx, resourceClaim) + if len(errs) != 0 { + t.Errorf("unexpected error validating for create %v", errs) + } +} + +func TestClaimStrategyUpdate(t *testing.T) { + t.Run("no-changes-okay", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaim := resourceClaim.DeepCopy() + newClaim := resourceClaim.DeepCopy() + newClaim.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClaim, resourceClaim) + errs := Strategy.ValidateUpdate(ctx, newClaim, resourceClaim) + if len(errs) != 0 { + t.Errorf("unexpected validation errors: %v", errs) + } + }) + + t.Run("name-change-not-allowed", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaim := resourceClaim.DeepCopy() + newClaim := resourceClaim.DeepCopy() + newClaim.Name = "valid-claim-2" + newClaim.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClaim, resourceClaim) + errs := Strategy.ValidateUpdate(ctx, newClaim, resourceClaim) + if len(errs) == 0 { + t.Errorf("expected a validation error") + } + }) +} diff --git a/pkg/registry/resource/resourceclaimtemplate/storage/storage.go b/pkg/registry/resource/resourceclaimtemplate/storage/storage.go new file mode 100644 index 00000000000..282db91006d --- /dev/null +++ b/pkg/registry/resource/resourceclaimtemplate/storage/storage.go @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/resource/resourceclaimtemplate" +) + +// REST implements a RESTStorage for ResourceClaimTemplate. +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against ResourceClass. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) { + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &resource.ResourceClaimTemplate{} }, + NewListFunc: func() runtime.Object { return &resource.ResourceClaimTemplateList{} }, + DefaultQualifiedResource: resource.Resource("resourceclaimtemplates"), + + CreateStrategy: resourceclaimtemplate.Strategy, + UpdateStrategy: resourceclaimtemplate.Strategy, + DeleteStrategy: resourceclaimtemplate.Strategy, + ReturnDeletedObject: true, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: resourceclaimtemplate.GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + + return &REST{store}, nil +} diff --git a/pkg/registry/resource/resourceclaimtemplate/storage/storage_test.go b/pkg/registry/resource/resourceclaimtemplate/storage/storage_test.go new file mode 100644 index 00000000000..642938b3bc2 --- /dev/null +++ b/pkg/registry/resource/resourceclaimtemplate/storage/storage_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" + etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" + "k8s.io/kubernetes/pkg/apis/resource" + _ "k8s.io/kubernetes/pkg/apis/resource/install" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "resourceclaimtemplates", + } + resourceClaimTemplateStorage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return resourceClaimTemplateStorage, server +} + +func validNewClaimTemplate(name string) *resource.ResourceClaimTemplate { + return &resource.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: resource.ResourceClaimTemplateSpec{ + Spec: resource.ResourceClaimSpec{ + ResourceClassName: "valid-class", + AllocationMode: resource.AllocationModeImmediate, + }, + }, + } +} + +func TestCreate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + resourceClaimTemplate := validNewClaimTemplate("foo") + resourceClaimTemplate.ObjectMeta = metav1.ObjectMeta{GenerateName: "foo"} + test.TestCreate( + // valid + resourceClaimTemplate, + // invalid + &resource.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "*BadName!"}, + }, + ) +} + +func TestUpdate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestUpdate( + // valid + validNewClaimTemplate("foo"), + // updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.ResourceClaimTemplate) + object.Labels = map[string]string{"a": "b"} + return object + }, + //invalid update + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.ResourceClaimTemplate) + object.Spec.Spec.ResourceClassName = "" + return object + }, + ) + +} + +func TestDelete(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ReturnDeletedObject() + test.TestDelete(validNewClaimTemplate("foo")) +} + +func TestGet(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestGet(validNewClaimTemplate("foo")) +} + +func TestList(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestList(validNewClaimTemplate("foo")) +} + +func TestWatch(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store) + test.TestWatch( + validNewClaimTemplate("foo"), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"foo": "bar"}, + }, + // matching fields + []fields.Set{ + {"metadata.name": "foo"}, + }, + // not matching fields + []fields.Set{ + {"metadata.name": "bar"}, + }, + ) +} diff --git a/pkg/registry/resource/resourceclaimtemplate/strategy.go b/pkg/registry/resource/resourceclaimtemplate/strategy.go new file mode 100644 index 00000000000..8c625c94d4b --- /dev/null +++ b/pkg/registry/resource/resourceclaimtemplate/strategy.go @@ -0,0 +1,94 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclaimtemplate + +import ( + "context" + "errors" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/apis/resource/validation" +) + +// resourceClaimTemplateStrategy implements behavior for ResourceClaimTemplate objects +type resourceClaimTemplateStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +var Strategy = resourceClaimTemplateStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} + +func (resourceClaimTemplateStrategy) NamespaceScoped() bool { + return true +} + +func (resourceClaimTemplateStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { +} + +func (resourceClaimTemplateStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + resourceClaimTemplate := obj.(*resource.ResourceClaimTemplate) + return validation.ValidateClaimTemplate(resourceClaimTemplate) +} + +func (resourceClaimTemplateStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (resourceClaimTemplateStrategy) Canonicalize(obj runtime.Object) { +} + +func (resourceClaimTemplateStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (resourceClaimTemplateStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { +} + +func (resourceClaimTemplateStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + errorList := validation.ValidateClaimTemplate(obj.(*resource.ResourceClaimTemplate)) + return append(errorList, validation.ValidateClaimTemplateUpdate(obj.(*resource.ResourceClaimTemplate), old.(*resource.ResourceClaimTemplate))...) +} + +func (resourceClaimTemplateStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (resourceClaimTemplateStrategy) AllowUnconditionalUpdate() bool { + return true +} + +// GetAttrs returns labels and fields of a given object for filtering purposes. +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + template, ok := obj.(*resource.ResourceClaimTemplate) + if !ok { + return nil, nil, errors.New("not a resourceclaimtemplate") + } + return labels.Set(template.Labels), toSelectableFields(template), nil +} + +// toSelectableFields returns a field set that represents the object +func toSelectableFields(template *resource.ResourceClaimTemplate) fields.Set { + fields := generic.ObjectMetaFieldsSet(&template.ObjectMeta, true) + return fields +} diff --git a/pkg/registry/resource/resourceclaimtemplate/strategy_test.go b/pkg/registry/resource/resourceclaimtemplate/strategy_test.go new file mode 100644 index 00000000000..47e25375321 --- /dev/null +++ b/pkg/registry/resource/resourceclaimtemplate/strategy_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclaimtemplate + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/resource" +) + +var resourceClaimTemplate = &resource.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-claim-template", + Namespace: "default", + }, + Spec: resource.ResourceClaimTemplateSpec{ + Spec: resource.ResourceClaimSpec{ + ResourceClassName: "valid-class", + AllocationMode: resource.AllocationModeImmediate, + }, + }, +} + +func TestClaimTemplateStrategy(t *testing.T) { + if !Strategy.NamespaceScoped() { + t.Errorf("ResourceClaimTemplate must be namespace scoped") + } + if Strategy.AllowCreateOnUpdate() { + t.Errorf("ResourceClaimTemplate should not allow create on update") + } +} + +func TestClaimTemplateStrategyCreate(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaimTemplate := resourceClaimTemplate.DeepCopy() + + Strategy.PrepareForCreate(ctx, resourceClaimTemplate) + errs := Strategy.Validate(ctx, resourceClaimTemplate) + if len(errs) != 0 { + t.Errorf("unexpected error validating for create %v", errs) + } +} + +func TestClaimTemplateStrategyUpdate(t *testing.T) { + t.Run("no-changes-okay", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaimTemplate := resourceClaimTemplate.DeepCopy() + newClaimTemplate := resourceClaimTemplate.DeepCopy() + newClaimTemplate.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate) + errs := Strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate) + if len(errs) != 0 { + t.Errorf("unexpected validation errors: %v", errs) + } + }) + + t.Run("name-change-not-allowed", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClaimTemplate := resourceClaimTemplate.DeepCopy() + newClaimTemplate := resourceClaimTemplate.DeepCopy() + newClaimTemplate.Name = "valid-class-2" + newClaimTemplate.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate) + errs := Strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate) + if len(errs) == 0 { + t.Errorf("expected a validation error") + } + }) +} diff --git a/pkg/registry/resource/resourceclass/storage/storage.go b/pkg/registry/resource/resourceclass/storage/storage.go new file mode 100644 index 00000000000..310488ab04e --- /dev/null +++ b/pkg/registry/resource/resourceclass/storage/storage.go @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/resource/resourceclass" +) + +// REST implements a RESTStorage for ResourceClass. +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against ResourceClass. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) { + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &resource.ResourceClass{} }, + NewListFunc: func() runtime.Object { return &resource.ResourceClassList{} }, + DefaultQualifiedResource: resource.Resource("resourceclasses"), + + CreateStrategy: resourceclass.Strategy, + UpdateStrategy: resourceclass.Strategy, + DeleteStrategy: resourceclass.Strategy, + ReturnDeletedObject: true, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + + return &REST{store}, nil +} diff --git a/pkg/registry/resource/resourceclass/storage/storage_test.go b/pkg/registry/resource/resourceclass/storage/storage_test.go new file mode 100644 index 00000000000..817623e7acc --- /dev/null +++ b/pkg/registry/resource/resourceclass/storage/storage_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2022 The Kubernetes 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 storage + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" + etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" + "k8s.io/kubernetes/pkg/apis/resource" + _ "k8s.io/kubernetes/pkg/apis/resource/install" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "resourceclasses", + } + resourceClassStorage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return resourceClassStorage, server +} + +func validNewClass(name string) *resource.ResourceClass { + return &resource.ResourceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + DriverName: "cdi.example.com", + } +} + +func TestCreate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope() + resourceClass := validNewClass("foo") + resourceClass.ObjectMeta = metav1.ObjectMeta{GenerateName: "foo"} + test.TestCreate( + // valid + resourceClass, + // invalid + &resource.ResourceClass{ + ObjectMeta: metav1.ObjectMeta{Name: "*BadName!"}, + }, + ) +} + +func TestUpdate(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope() + test.TestUpdate( + // valid + validNewClass("foo"), + // updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.ResourceClass) + object.ParametersRef = &resource.ResourceClassParametersReference{Kind: "cdiexample", Name: "some-name"} + return object + }, + //invalid update + func(obj runtime.Object) runtime.Object { + object := obj.(*resource.ResourceClass) + object.DriverName = "" + return object + }, + ) + +} + +func TestDelete(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope().ReturnDeletedObject() + test.TestDelete(validNewClass("foo")) +} + +func TestGet(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope() + test.TestGet(validNewClass("foo")) +} + +func TestList(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope() + test.TestList(validNewClass("foo")) +} + +func TestWatch(t *testing.T) { + storage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := genericregistrytest.New(t, storage.Store).ClusterScope() + test.TestWatch( + validNewClass("foo"), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"foo": "bar"}, + }, + // matching fields + []fields.Set{ + {"metadata.name": "foo"}, + }, + // not matching fields + []fields.Set{ + {"metadata.name": "bar"}, + }, + ) +} diff --git a/pkg/registry/resource/resourceclass/strategy.go b/pkg/registry/resource/resourceclass/strategy.go new file mode 100644 index 00000000000..55f98fe4065 --- /dev/null +++ b/pkg/registry/resource/resourceclass/strategy.go @@ -0,0 +1,75 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclass + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + "k8s.io/kubernetes/pkg/apis/resource/validation" +) + +// resourceClassStrategy implements behavior for ResourceClass objects +type resourceClassStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +var Strategy = resourceClassStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} + +func (resourceClassStrategy) NamespaceScoped() bool { + return false +} + +func (resourceClassStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { +} + +func (resourceClassStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + resourceClass := obj.(*resource.ResourceClass) + return validation.ValidateClass(resourceClass) +} + +func (resourceClassStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (resourceClassStrategy) Canonicalize(obj runtime.Object) { +} + +func (resourceClassStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (resourceClassStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { +} + +func (resourceClassStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + errorList := validation.ValidateClass(obj.(*resource.ResourceClass)) + return append(errorList, validation.ValidateClassUpdate(obj.(*resource.ResourceClass), old.(*resource.ResourceClass))...) +} + +func (resourceClassStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (resourceClassStrategy) AllowUnconditionalUpdate() bool { + return true +} diff --git a/pkg/registry/resource/resourceclass/strategy_test.go b/pkg/registry/resource/resourceclass/strategy_test.go new file mode 100644 index 00000000000..d55f8066769 --- /dev/null +++ b/pkg/registry/resource/resourceclass/strategy_test.go @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Kubernetes 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 resourceclass + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/resource" +) + +var resourceClass = &resource.ResourceClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-class", + }, + DriverName: "resource-driver.example.com", +} + +func TestClassStrategy(t *testing.T) { + if Strategy.NamespaceScoped() { + t.Errorf("ResourceClass must not be namespace scoped") + } + if Strategy.AllowCreateOnUpdate() { + t.Errorf("ResourceClass should not allow create on update") + } +} + +func TestClassStrategyCreate(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClass := resourceClass.DeepCopy() + + Strategy.PrepareForCreate(ctx, resourceClass) + errs := Strategy.Validate(ctx, resourceClass) + if len(errs) != 0 { + t.Errorf("unexpected error validating for create %v", errs) + } +} + +func TestClassStrategyUpdate(t *testing.T) { + t.Run("no-changes-okay", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClass := resourceClass.DeepCopy() + newClass := resourceClass.DeepCopy() + newClass.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClass, resourceClass) + errs := Strategy.ValidateUpdate(ctx, newClass, resourceClass) + if len(errs) != 0 { + t.Errorf("unexpected validation errors: %v", errs) + } + }) + + t.Run("name-change-not-allowed", func(t *testing.T) { + ctx := genericapirequest.NewDefaultContext() + resourceClass := resourceClass.DeepCopy() + newClass := resourceClass.DeepCopy() + newClass.Name = "valid-class-2" + newClass.ResourceVersion = "4" + + Strategy.PrepareForUpdate(ctx, newClass, resourceClass) + errs := Strategy.ValidateUpdate(ctx, newClass, resourceClass) + if len(errs) == 0 { + t.Errorf("expected a validation error") + } + }) +} diff --git a/pkg/registry/resource/rest/storage_resource.go b/pkg/registry/resource/rest/storage_resource.go new file mode 100644 index 00000000000..93d793e466a --- /dev/null +++ b/pkg/registry/resource/rest/storage_resource.go @@ -0,0 +1,91 @@ +/* +Copyright 2022 The Kubernetes 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 rest + +import ( + resourcev1alpha1 "k8s.io/api/resource/v1alpha1" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + serverstorage "k8s.io/apiserver/pkg/server/storage" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/resource" + podschedulingstore "k8s.io/kubernetes/pkg/registry/resource/podscheduling/storage" + resourceclaimstore "k8s.io/kubernetes/pkg/registry/resource/resourceclaim/storage" + resourceclaimtemplatestore "k8s.io/kubernetes/pkg/registry/resource/resourceclaimtemplate/storage" + resourceclassstore "k8s.io/kubernetes/pkg/registry/resource/resourceclass/storage" +) + +type RESTStorageProvider struct{} + +func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(resource.GroupName, legacyscheme.Scheme, legacyscheme.ParameterCodec, legacyscheme.Codecs) + // If you add a version here, be sure to add an entry in `k8s.io/kubernetes/cmd/kube-apiserver/app/aggregator.go with specific priorities. + // TODO refactor the plumbing to provide the information in the APIGroupInfo + + if storageMap, err := p.v1alpha1Storage(apiResourceConfigSource, restOptionsGetter); err != nil { + return genericapiserver.APIGroupInfo{}, err + } else if len(storageMap) > 0 { + apiGroupInfo.VersionedResourcesStorageMap[resourcev1alpha1.SchemeGroupVersion.Version] = storageMap + } + + return apiGroupInfo, nil +} + +func (p RESTStorageProvider) v1alpha1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { + storage := map[string]rest.Storage{} + + if resource := "resourceclasses"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha1.SchemeGroupVersion.WithResource(resource)) { + resourceClassStorage, err := resourceclassstore.NewREST(restOptionsGetter) + if err != nil { + return nil, err + } + storage[resource] = resourceClassStorage + } + + if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha1.SchemeGroupVersion.WithResource(resource)) { + resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter) + if err != nil { + return nil, err + } + storage[resource] = resourceClaimStorage + storage[resource+"/status"] = resourceClaimStatusStorage + } + + if resource := "resourceclaimtemplates"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha1.SchemeGroupVersion.WithResource(resource)) { + resourceClaimTemplateStorage, err := resourceclaimtemplatestore.NewREST(restOptionsGetter) + if err != nil { + return nil, err + } + storage[resource] = resourceClaimTemplateStorage + } + + if resource := "podschedulings"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha1.SchemeGroupVersion.WithResource(resource)) { + podSchedulingStorage, podSchedulingStatusStorage, err := podschedulingstore.NewREST(restOptionsGetter) + if err != nil { + return nil, err + } + storage[resource] = podSchedulingStorage + storage[resource+"/status"] = podSchedulingStatusStorage + } + + return storage, nil +} + +func (p RESTStorageProvider) GroupName() string { + return resource.GroupName +} diff --git a/staging/src/k8s.io/api/resource/OWNERS b/staging/src/k8s.io/api/resource/OWNERS new file mode 100644 index 00000000000..98397072ae5 --- /dev/null +++ b/staging/src/k8s.io/api/resource/OWNERS @@ -0,0 +1,6 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +reviewers: + - bart0sh + - klueska + - pohly diff --git a/staging/src/k8s.io/api/resource/v1alpha1/doc.go b/staging/src/k8s.io/api/resource/v1alpha1/doc.go new file mode 100644 index 00000000000..8fa577fabc8 --- /dev/null +++ b/staging/src/k8s.io/api/resource/v1alpha1/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2022 The Kubernetes 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. +*/ + +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen=package +// +k8s:protobuf-gen=package + +// +groupName=resource.k8s.io + +// Package v1alpha1 is the v1alpha1 version of the resource API. +package v1alpha1 // import "k8s.io/api/resource/v1alpha1" diff --git a/staging/src/k8s.io/api/resource/v1alpha1/register.go b/staging/src/k8s.io/api/resource/v1alpha1/register.go new file mode 100644 index 00000000000..8245b9aee5d --- /dev/null +++ b/staging/src/k8s.io/api/resource/v1alpha1/register.go @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Kubernetes 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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "resource.k8s.io" + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &ResourceClass{}, + &ResourceClassList{}, + &ResourceClaim{}, + &ResourceClaimList{}, + &ResourceClaimTemplate{}, + &ResourceClaimTemplateList{}, + &PodScheduling{}, + &PodSchedulingList{}, + ) + + // Add common types + scheme.AddKnownTypes(SchemeGroupVersion, &metav1.Status{}) + + // Add the watch version that applies + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/staging/src/k8s.io/api/resource/v1alpha1/types.go b/staging/src/k8s.io/api/resource/v1alpha1/types.go new file mode 100644 index 00000000000..9d7d4a191af --- /dev/null +++ b/staging/src/k8s.io/api/resource/v1alpha1/types.go @@ -0,0 +1,429 @@ +/* +Copyright 2022 The Kubernetes 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 ( + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClaim describes which resources are needed by a resource consumer. +// Its status tracks whether the resource has been allocated and what the +// resulting attributes are. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type ResourceClaim struct { + metav1.TypeMeta `json:",inline"` + // Standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Spec describes the desired attributes of a resource that then needs + // to be allocated. It can only be set once when creating the + // ResourceClaim. + Spec ResourceClaimSpec `json:"spec" protobuf:"bytes,2,name=spec"` + + // Status describes whether the resource is available and with which + // attributes. + // +optional + Status ResourceClaimStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"` +} + +// ResourceClaimSpec defines how a resource is to be allocated. +type ResourceClaimSpec struct { + // ResourceClassName references the driver and additional parameters + // via the name of a ResourceClass that was created as part of the + // driver deployment. + ResourceClassName string `json:"resourceClassName" protobuf:"bytes,1,name=resourceClassName"` + + // ParametersRef references a separate object with arbitrary parameters + // that will be used by the driver when allocating a resource for the + // claim. + // + // The object must be in the same namespace as the ResourceClaim. + // +optional + ParametersRef *ResourceClaimParametersReference `json:"parametersRef,omitempty" protobuf:"bytes,2,opt,name=parametersRef"` + + // Allocation can start immediately or when a Pod wants to use the + // resource. "WaitForFirstConsumer" is the default. + // +optional + AllocationMode AllocationMode `json:"allocationMode,omitempty" protobuf:"bytes,3,opt,name=allocationMode"` +} + +// AllocationMode describes whether a ResourceClaim gets allocated immediately +// when it gets created (AllocationModeImmediate) or whether allocation is +// delayed until it is needed for a Pod +// (AllocationModeWaitForFirstConsumer). Other modes might get added in the +// future. +type AllocationMode string + +const ( + // When a ResourceClaim has AllocationModeWaitForFirstConsumer, allocation is + // delayed until a Pod gets scheduled that needs the ResourceClaim. The + // scheduler will consider all resource requirements of that Pod and + // trigger allocation for a node that fits the Pod. + AllocationModeWaitForFirstConsumer AllocationMode = "WaitForFirstConsumer" + + // When a ResourceClaim has AllocationModeImmediate, allocation starts + // as soon as the ResourceClaim gets created. This is done without + // considering the needs of Pods that will use the ResourceClaim + // because those Pods are not known yet. + AllocationModeImmediate AllocationMode = "Immediate" +) + +// ResourceClaimStatus tracks whether the resource has been allocated and what +// the resulting attributes are. +type ResourceClaimStatus struct { + // DriverName is a copy of the driver name from the ResourceClass at + // the time when allocation started. + // +optional + DriverName string `json:"driverName,omitempty" protobuf:"bytes,1,opt,name=driverName"` + + // Allocation is set by the resource driver once a resource has been + // allocated successfully. If this is not specified, the resource is + // not yet allocated. + // +optional + Allocation *AllocationResult `json:"allocation,omitempty" protobuf:"bytes,2,opt,name=allocation"` + + // ReservedFor indicates which entities are currently allowed to use + // the claim. A Pod which references a ResourceClaim which is not + // reserved for that Pod will not be started. + // + // There can be at most 32 such reservations. This may get increased in + // the future, but not reduced. + // + // +listType=set + // +optional + ReservedFor []ResourceClaimConsumerReference `json:"reservedFor,omitempty" protobuf:"bytes,3,opt,name=reservedFor"` + + // DeallocationRequested indicates that a ResourceClaim is to be + // deallocated. + // + // The driver then must deallocate this claim and reset the field + // together with clearing the Allocation field. + // + // While DeallocationRequested is set, no new consumers may be added to + // ReservedFor. + // +optional + DeallocationRequested bool `json:"deallocationRequested,omitempty" protobuf:"varint,4,opt,name=deallocationRequested"` +} + +// ReservedForMaxSize is the maximum number of entries in +// claim.status.reservedFor. +const ResourceClaimReservedForMaxSize = 32 + +// AllocationResult contains attributed of an allocated resource. +type AllocationResult struct { + // ResourceHandle contains arbitrary data returned by the driver after a + // successful allocation. This is opaque for + // Kubernetes. Driver documentation may explain to users how to + // interpret this data if needed. + // + // The maximum size of this field is 16KiB. This may get + // increased in the future, but not reduced. + // +optional + ResourceHandle string `json:"resourceHandle,omitempty" protobuf:"bytes,1,opt,name=resourceHandle"` + + // This field will get set by the resource driver after it has + // allocated the resource driver to inform the scheduler where it can + // schedule Pods using the ResourceClaim. + // + // Setting this field is optional. If null, the resource is available + // everywhere. + // +optional + AvailableOnNodes *v1.NodeSelector `json:"availableOnNodes,omitempty" protobuf:"bytes,2,opt,name=availableOnNodes"` + + // Shareable determines whether the resource supports more + // than one consumer at a time. + // +optional + Shareable bool `json:"shareable,omitempty" protobuf:"varint,3,opt,name=shareable"` +} + +// ResourceHandleMaxSize is the maximum size of allocation.resourceHandle. +const ResourceHandleMaxSize = 16 * 1024 + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClaimList is a collection of claims. +type ResourceClaimList struct { + metav1.TypeMeta `json:",inline"` + // Standard list metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Items is the list of resource claims. + Items []ResourceClaim `json:"items" protobuf:"bytes,2,rep,name=items"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// PodScheduling objects hold information that is needed to schedule +// a Pod with ResourceClaims that use "WaitForFirstConsumer" allocation +// mode. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type PodScheduling struct { + metav1.TypeMeta `json:",inline"` + // Standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Spec describes where resources for the Pod are needed. + Spec PodSchedulingSpec `json:"spec" protobuf:"bytes,2,name=spec"` + + // Status describes where resources for the Pod can be allocated. + // +optional + Status PodSchedulingStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"` +} + +// PodSchedulingSpec describes where resources for the Pod are needed. +type PodSchedulingSpec struct { + // SelectedNode is the node for which allocation of ResourceClaims that + // are referenced by the Pod and that use "WaitForFirstConsumer" + // allocation is to be attempted. + // +optional + SelectedNode string `json:"selectedNode,omitempty" protobuf:"bytes,1,opt,name=selectedNode"` + + // PotentialNodes lists nodes where the Pod might be able to run. + // + // The size of this field is limited to 128. This is large enough for + // many clusters. Larger clusters may need more attempts to find a node + // that suits all pending resources. This may get increased in the + // future, but not reduced. + // + // +listType=set + // +optional + PotentialNodes []string `json:"potentialNodes,omitempty" protobuf:"bytes,2,opt,name=potentialNodes"` +} + +// PodSchedulingStatus describes where resources for the Pod can be allocated. +type PodSchedulingStatus struct { + // ResourceClaims describes resource availability for each + // pod.spec.resourceClaim entry where the corresponding ResourceClaim + // uses "WaitForFirstConsumer" allocation mode. + // + // +listType=map + // +listMapKey=name + // +optional + ResourceClaims []ResourceClaimSchedulingStatus `json:"resourceClaims,omitempty" protobuf:"bytes,1,opt,name=resourceClaims"` + + // If there ever is a need to support other kinds of resources + // than ResourceClaim, then new fields could get added here + // for those other resources. +} + +// ResourceClaimSchedulingStatus contains information about one particular +// ResourceClaim with "WaitForFirstConsumer" allocation mode. +type ResourceClaimSchedulingStatus struct { + // Name matches the pod.spec.resourceClaims[*].Name field. + // +optional + Name string `json:"name,omitempty" protobuf:"bytes,1,opt,name=name"` + + // UnsuitableNodes lists nodes that the ResourceClaim cannot be + // allocated for. + // + // The size of this field is limited to 128, the same as for + // PodSchedulingSpec.PotentialNodes. This may get increased in the + // future, but not reduced. + // + // +listType=set + // +optional + UnsuitableNodes []string `json:"unsuitableNodes,omitempty" protobuf:"bytes,2,opt,name=unsuitableNodes"` +} + +// PodSchedulingNodeListMaxSize defines the maximum number of entries in the +// node lists that are stored in PodScheduling objects. This limit is part +// of the API. +const PodSchedulingNodeListMaxSize = 128 + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// PodSchedulingList is a collection of Pod scheduling objects. +type PodSchedulingList struct { + metav1.TypeMeta `json:",inline"` + // Standard list metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Items is the list of PodScheduling objects. + Items []PodScheduling `json:"items" protobuf:"bytes,2,rep,name=items"` +} + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClass is used by administrators to influence how resources +// are allocated. +// +// This is an alpha type and requires enabling the DynamicResourceAllocation +// feature gate. +type ResourceClass struct { + metav1.TypeMeta `json:",inline"` + // Standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // DriverName defines the name of the dynamic resource driver that is + // used for allocation of a ResourceClaim that uses this class. + // + // Resource drivers have a unique name in forward domain order + // (acme.example.com). + DriverName string `json:"driverName" protobuf:"bytes,2,name=driverName"` + + // ParametersRef references an arbitrary separate object that may hold + // parameters that will be used by the driver when allocating a + // resource that uses this class. A dynamic resource driver can + // distinguish between parameters stored here and and those stored in + // ResourceClaimSpec. + // +optional + ParametersRef *ResourceClassParametersReference `json:"parametersRef,omitempty" protobuf:"bytes,3,opt,name=parametersRef"` + + // Only nodes matching the selector will be considered by the scheduler + // when trying to find a Node that fits a Pod when that Pod uses + // a ResourceClaim that has not been allocated yet. + // + // Setting this field is optional. If null, all nodes are candidates. + // +optional + SuitableNodes *v1.NodeSelector `json:"suitableNodes,omitempty" protobuf:"bytes,4,opt,name=suitableNodes"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClassList is a collection of classes. +type ResourceClassList struct { + metav1.TypeMeta `json:",inline"` + // Standard list metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Items is the list of resource classes. + Items []ResourceClass `json:"items" protobuf:"bytes,2,rep,name=items"` +} + +// ResourceClassParametersReference contains enough information to let you +// locate the parameters for a ResourceClass. +type ResourceClassParametersReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string `json:"apiGroup,omitempty" protobuf:"bytes,1,opt,name=apiGroup"` + // Kind is the type of resource being referenced. This is the same + // value as in the parameter object's metadata. + Kind string `json:"kind" protobuf:"bytes,2,name=kind"` + // Name is the name of resource being referenced. + Name string `json:"name" protobuf:"bytes,3,name=name"` + // Namespace that contains the referenced resource. Must be empty + // for cluster-scoped resources and non-empty for namespaced + // resources. + // +optional + Namespace string `json:"namespace,omitempty" protobuf:"bytes,4,opt,name=namespace"` +} + +// ResourceClaimParametersReference contains enough information to let you +// locate the parameters for a ResourceClaim. The object must be in the same +// namespace as the ResourceClaim. +type ResourceClaimParametersReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string `json:"apiGroup,omitempty" protobuf:"bytes,1,opt,name=apiGroup"` + // Kind is the type of resource being referenced. This is the same + // value as in the parameter object's metadata, for example "ConfigMap". + Kind string `json:"kind" protobuf:"bytes,2,name=kind"` + // Name is the name of resource being referenced. + Name string `json:"name" protobuf:"bytes,3,name=name"` +} + +// ResourceClaimConsumerReference contains enough information to let you +// locate the consumer of a ResourceClaim. The user must be a resource in the same +// namespace as the ResourceClaim. +type ResourceClaimConsumerReference struct { + // APIGroup is the group for the resource being referenced. It is + // empty for the core API. This matches the group in the APIVersion + // that is used when creating the resources. + // +optional + APIGroup string `json:"apiGroup,omitempty" protobuf:"bytes,1,opt,name=apiGroup"` + // Resource is the type of resource being referenced, for example "pods". + Resource string `json:"resource" protobuf:"bytes,3,name=resource"` + // Name is the name of resource being referenced. + Name string `json:"name" protobuf:"bytes,4,name=name"` + // UID identifies exactly one incarnation of the resource. + UID types.UID `json:"uid" protobuf:"bytes,5,name=uid"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClaimTemplate is used to produce ResourceClaim objects. +type ResourceClaimTemplate struct { + metav1.TypeMeta `json:",inline"` + // Standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Describes the ResourceClaim that is to be generated. + // + // This field is immutable. A ResourceClaim will get created by the + // control plane for a Pod when needed and then not get updated + // anymore. + Spec ResourceClaimTemplateSpec `json:"spec" protobuf:"bytes,2,name=spec"` +} + +// ResourceClaimTemplateSpec contains the metadata and fields for a ResourceClaim. +type ResourceClaimTemplateSpec struct { + // ObjectMeta may contain labels and annotations that will be copied into the PVC + // when creating it. No other fields are allowed and will be rejected during + // validation. + // +optional + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Spec for the ResourceClaim. The entire content is copied unchanged + // into the ResourceClaim that gets created from this template. The + // same fields as in a ResourceClaim are also valid here. + Spec ResourceClaimSpec `json:"spec" protobuf:"bytes,2,name=spec"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.26 + +// ResourceClaimTemplateList is a collection of claim templates. +type ResourceClaimTemplateList struct { + metav1.TypeMeta `json:",inline"` + // Standard list metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // Items is the list of resource claim templates. + Items []ResourceClaimTemplate `json:"items" protobuf:"bytes,2,rep,name=items"` +} diff --git a/staging/src/k8s.io/api/roundtrip_test.go b/staging/src/k8s.io/api/roundtrip_test.go index 004b2fc341b..1c326766fe5 100644 --- a/staging/src/k8s.io/api/roundtrip_test.go +++ b/staging/src/k8s.io/api/roundtrip_test.go @@ -66,6 +66,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" rbacv1alpha1 "k8s.io/api/rbac/v1alpha1" rbacv1beta1 "k8s.io/api/rbac/v1beta1" + resourcev1alpha1 "k8s.io/api/resource/v1alpha1" schedulingv1 "k8s.io/api/scheduling/v1" schedulingv1alpha1 "k8s.io/api/scheduling/v1alpha1" schedulingv1beta1 "k8s.io/api/scheduling/v1beta1" @@ -128,6 +129,7 @@ var groups = []runtime.SchemeBuilder{ rbacv1alpha1.SchemeBuilder, rbacv1beta1.SchemeBuilder, rbacv1.SchemeBuilder, + resourcev1alpha1.SchemeBuilder, schedulingv1alpha1.SchemeBuilder, schedulingv1beta1.SchemeBuilder, schedulingv1.SchemeBuilder, diff --git a/test/integration/apiserver/apply/reset_fields_test.go b/test/integration/apiserver/apply/reset_fields_test.go index 20a25188af4..ff4f6d696c9 100644 --- a/test/integration/apiserver/apply/reset_fields_test.go +++ b/test/integration/apiserver/apply/reset_fields_test.go @@ -64,6 +64,8 @@ var resetFieldsStatusData = map[schema.GroupVersionResource]string{ gvr("storage.k8s.io", "v1", "volumeattachments"): `{"status": {"attached": false}}`, gvr("policy", "v1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 25}}`, gvr("policy", "v1beta1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 25}}`, + gvr("resource.k8s.io", "v1alpha1", "podschedulings"): `{"status": {"resourceClaims": [{"name": "my-claim", "unsuitableNodes": ["node2"]}]}}`, // Not really a conflict with status_test.go: Apply just stores both nodes. Conflict testing therefore gets disabled for podschedulings. + gvr("resource.k8s.io", "v1alpha1", "resourceclaims"): `{"status": {"driverName": "other.example.com"}}`, gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"False","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, } @@ -84,6 +86,10 @@ var noConflicts = map[string]struct{}{ // namespaces only have a spec.finalizers field which is also skipped, // thus it will never have a conflict. "namespaces": {}, + // podschedulings.status only has a list which contains items with a list, + // therefore apply works because it simply merges either the outer or + // the inner list. + "podschedulings": {}, } var image2 = image.GetE2EImage(image.Etcd) @@ -140,6 +146,10 @@ var resetFieldsSpecData = map[schema.GroupVersionResource]string{ gvr("awesome.bears.com", "v3", "pandas"): `{"spec": {"replicas": 302}}`, gvr("apiregistration.k8s.io", "v1beta1", "apiservices"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"group": "foo2.com"}}`, gvr("apiregistration.k8s.io", "v1", "apiservices"): `{"metadata": {"labels": {"a":"c"}}, "spec": {"group": "foo2.com"}}`, + gvr("resource.k8s.io", "v1alpha1", "podschedulings"): `{"spec": {"selectedNode": "node2name"}}`, + gvr("resource.k8s.io", "v1alpha1", "resourceclasses"): `{"driverName": "other.example.com"}`, + gvr("resource.k8s.io", "v1alpha1", "resourceclaims"): `{"spec": {"resourceClassName": "class2name"}}`, // ResourceClassName is immutable, but that doesn't matter for the test. + gvr("resource.k8s.io", "v1alpha1", "resourceclaimtemplates"): `{"spec": {"spec": {"resourceClassName": "class2name"}}}`, gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{}`, } diff --git a/test/integration/apiserver/apply/status_test.go b/test/integration/apiserver/apply/status_test.go index 8aca1c8ea51..8d4593f14d7 100644 --- a/test/integration/apiserver/apply/status_test.go +++ b/test/integration/apiserver/apply/status_test.go @@ -54,6 +54,8 @@ var statusData = map[schema.GroupVersionResource]string{ gvr("storage.k8s.io", "v1", "volumeattachments"): `{"status": {"attached": true}}`, gvr("policy", "v1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 5}}`, gvr("policy", "v1beta1", "poddisruptionbudgets"): `{"status": {"currentHealthy": 5}}`, + gvr("resource.k8s.io", "v1alpha1", "podschedulings"): `{"status": {"resourceClaims": [{"name": "my-claim", "unsuitableNodes": ["node1"]}]}}`, + gvr("resource.k8s.io", "v1alpha1", "resourceclaims"): `{"status": {"driverName": "example.com"}}`, gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): `{"status": {"commonEncodingVersion":"v1","storageVersions":[{"apiServerID":"1","decodableVersions":["v1","v2"],"encodingVersion":"v1"}],"conditions":[{"type":"AllEncodingVersionsEqual","status":"True","lastTransitionTime":"2020-01-01T00:00:00Z","reason":"allEncodingVersionsEqual","message":"all encoding versions are set to v1"}]}}`, } diff --git a/test/integration/etcd/data.go b/test/integration/etcd/data.go index 8177c2479e7..2ac3c88f5d4 100644 --- a/test/integration/etcd/data.go +++ b/test/integration/etcd/data.go @@ -459,6 +459,25 @@ func GetEtcdStorageDataForNamespace(namespace string) map[schema.GroupVersionRes }, // -- + // k8s.io/kubernetes/pkg/apis/resource/v1alpha1 + gvr("resource.k8s.io", "v1alpha1", "resourceclasses"): { + Stub: `{"metadata": {"name": "class1name"}, "driverName": "example.com"}`, + ExpectedEtcdPath: "/registry/resourceclasses/class1name", + }, + gvr("resource.k8s.io", "v1alpha1", "resourceclaims"): { + Stub: `{"metadata": {"name": "claim1name"}, "spec": {"resourceClassName": "class1name", "allocationMode": "WaitForFirstConsumer"}}`, + ExpectedEtcdPath: "/registry/resourceclaims/" + namespace + "/claim1name", + }, + gvr("resource.k8s.io", "v1alpha1", "resourceclaimtemplates"): { + Stub: `{"metadata": {"name": "claimtemplate1name"}, "spec": {"spec": {"resourceClassName": "class1name", "allocationMode": "WaitForFirstConsumer"}}}`, + ExpectedEtcdPath: "/registry/resourceclaimtemplates/" + namespace + "/claimtemplate1name", + }, + gvr("resource.k8s.io", "v1alpha1", "podschedulings"): { + Stub: `{"metadata": {"name": "pod1name"}, "spec": {"selectedNode": "node1name", "potentialNodes": ["node1name", "node2name"]}}`, + ExpectedEtcdPath: "/registry/podschedulings/" + namespace + "/pod1name", + }, + // -- + // k8s.io/apiserver/pkg/apis/apiserverinternal/v1alpha1 gvr("internal.apiserver.k8s.io", "v1alpha1", "storageversions"): { Stub: `{"metadata":{"name":"sv1.test"},"spec":{}}`,