From fc558fb4e9fa9da1dc583f5c71f27cd54debc4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20K=C5=99epinsk=C3=BD?= Date: Tue, 17 Feb 2026 17:37:51 +0100 Subject: [PATCH] introduce EvictionRequest and Eviction API to new lifecycle group - EvictionRequest is managed by a requester - Eviction is managed by the evictionrequest-controller - add storage, strategy, printers and validation --- cmd/kube-apiserver/app/aggregator.go | 1 + .../app/import_known_versions.go | 1 + hack/lib/init.sh | 1 + pkg/api/testing/install.go | 1 + pkg/apis/lifecycle/doc.go | 21 + pkg/apis/lifecycle/install/install.go | 38 + pkg/apis/lifecycle/register.go | 56 + pkg/apis/lifecycle/types.go | 590 +++++ pkg/apis/lifecycle/v1alpha1/doc.go | 26 + pkg/apis/lifecycle/v1alpha1/register.go | 46 + pkg/apis/lifecycle/v1alpha1/validation.go | 37 + pkg/apis/lifecycle/validation/validation.go | 616 +++++ .../lifecycle/validation/validation_test.go | 1993 +++++++++++++++++ pkg/controlplane/import_known_versions.go | 1 + pkg/controlplane/instance.go | 4 + pkg/controlplane/instance_test.go | 4 +- .../default_storage_factory_builder.go | 3 + .../cmd/convert/import_known_versions.go | 1 + .../internalversion/import_known_versions.go | 1 + pkg/printers/internalversion/printers.go | 216 ++ pkg/printers/internalversion/printers_test.go | 655 ++++++ pkg/registry/lifecycle/eviction/doc.go | 17 + .../lifecycle/eviction/storage/storage.go | 107 + .../eviction/storage/storage_test.go | 274 +++ pkg/registry/lifecycle/eviction/strategy.go | 182 ++ .../lifecycle/eviction/strategy_test.go | 296 +++ pkg/registry/lifecycle/evictionrequest/doc.go | 17 + .../evictionrequest/storage/storage.go | 106 + .../evictionrequest/storage/storage_test.go | 263 +++ .../lifecycle/evictionrequest/strategy.go | 177 ++ .../evictionrequest/strategy_test.go | 271 +++ .../lifecycle/rest/storage_coordination.go | 85 + .../authorizer/rbac/bootstrappolicy/policy.go | 8 + .../testdata/cluster-roles-featuregates.yaml | 19 + .../src/k8s.io/api/lifecycle/v1alpha1/doc.go | 25 + .../k8s.io/api/lifecycle/v1alpha1/register.go | 55 + .../k8s.io/api/lifecycle/v1alpha1/types.go | 680 ++++++ staging/src/k8s.io/api/roundtrip_test.go | 2 + .../client-go/kubernetes/fake/register.go | 2 + .../client-go/kubernetes/scheme/register.go | 2 + .../core/pod/eviction_responders.go | 16 + .../eviction/declarative_validation_test.go | 653 ++++++ .../declarative_validation_test.go | 402 ++++ test/integration/etcd/data.go | 17 + 44 files changed, 7987 insertions(+), 1 deletion(-) create mode 100644 pkg/apis/lifecycle/doc.go create mode 100644 pkg/apis/lifecycle/install/install.go create mode 100644 pkg/apis/lifecycle/register.go create mode 100644 pkg/apis/lifecycle/types.go create mode 100644 pkg/apis/lifecycle/v1alpha1/doc.go create mode 100644 pkg/apis/lifecycle/v1alpha1/register.go create mode 100644 pkg/apis/lifecycle/v1alpha1/validation.go create mode 100644 pkg/apis/lifecycle/validation/validation.go create mode 100644 pkg/apis/lifecycle/validation/validation_test.go create mode 100644 pkg/registry/lifecycle/eviction/doc.go create mode 100644 pkg/registry/lifecycle/eviction/storage/storage.go create mode 100644 pkg/registry/lifecycle/eviction/storage/storage_test.go create mode 100644 pkg/registry/lifecycle/eviction/strategy.go create mode 100644 pkg/registry/lifecycle/eviction/strategy_test.go create mode 100644 pkg/registry/lifecycle/evictionrequest/doc.go create mode 100644 pkg/registry/lifecycle/evictionrequest/storage/storage.go create mode 100644 pkg/registry/lifecycle/evictionrequest/storage/storage_test.go create mode 100644 pkg/registry/lifecycle/evictionrequest/strategy.go create mode 100644 pkg/registry/lifecycle/evictionrequest/strategy_test.go create mode 100644 pkg/registry/lifecycle/rest/storage_coordination.go create mode 100644 staging/src/k8s.io/api/lifecycle/v1alpha1/doc.go create mode 100644 staging/src/k8s.io/api/lifecycle/v1alpha1/register.go create mode 100644 staging/src/k8s.io/api/lifecycle/v1alpha1/types.go create mode 100644 test/declarative_validation/lifecycle/eviction/declarative_validation_test.go create mode 100644 test/declarative_validation/lifecycle/evictionrequest/declarative_validation_test.go diff --git a/cmd/kube-apiserver/app/aggregator.go b/cmd/kube-apiserver/app/aggregator.go index 83efe3a6dc8..9da215fd41c 100644 --- a/cmd/kube-apiserver/app/aggregator.go +++ b/cmd/kube-apiserver/app/aggregator.go @@ -56,6 +56,7 @@ var apiVersionPriorities = merge(controlplaneapiserver.DefaultGenericAPIServiceP {Group: "resource.k8s.io", Version: "v1beta2"}: {Group: 16200, Version: 15}, {Group: "resource.k8s.io", Version: "v1beta1"}: {Group: 16200, Version: 9}, {Group: "resource.k8s.io", Version: "v1alpha3"}: {Group: 16200, Version: 1}, + {Group: "lifecycle.k8s.io", Version: "v1alpha1"}: {Group: 15700, Version: 1}, // 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/cmd/kube-controller-manager/app/import_known_versions.go b/cmd/kube-controller-manager/app/import_known_versions.go index 5b3dfa7cd71..8714531ad89 100644 --- a/cmd/kube-controller-manager/app/import_known_versions.go +++ b/cmd/kube-controller-manager/app/import_known_versions.go @@ -31,6 +31,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/core/install" _ "k8s.io/kubernetes/pkg/apis/events/install" _ "k8s.io/kubernetes/pkg/apis/extensions/install" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" _ "k8s.io/kubernetes/pkg/apis/resource/install" diff --git a/hack/lib/init.sh b/hack/lib/init.sh index ddcff62340d..0e49cca5ef4 100755 --- a/hack/lib/init.sh +++ b/hack/lib/init.sh @@ -93,6 +93,7 @@ extensions/v1beta1 \ events.k8s.io/v1 \ events.k8s.io/v1beta1 \ imagepolicy.k8s.io/v1alpha1 \ +lifecycle.k8s.io/v1alpha1 \ networking.k8s.io/v1 \ networking.k8s.io/v1beta1 \ node.k8s.io/v1 \ diff --git a/pkg/api/testing/install.go b/pkg/api/testing/install.go index e596d7e543c..6d2b940ee3a 100644 --- a/pkg/api/testing/install.go +++ b/pkg/api/testing/install.go @@ -33,6 +33,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/extensions/install" _ "k8s.io/kubernetes/pkg/apis/flowcontrol/install" _ "k8s.io/kubernetes/pkg/apis/imagepolicy/install" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" _ "k8s.io/kubernetes/pkg/apis/networking/install" _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" diff --git a/pkg/apis/lifecycle/doc.go b/pkg/apis/lifecycle/doc.go new file mode 100644 index 00000000000..19b76d6aeb5 --- /dev/null +++ b/pkg/apis/lifecycle/doc.go @@ -0,0 +1,21 @@ +/* +Copyright 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 + +// +groupName=lifecycle.k8s.io + +package lifecycle diff --git a/pkg/apis/lifecycle/install/install.go b/pkg/apis/lifecycle/install/install.go new file mode 100644 index 00000000000..c65c3fcaa12 --- /dev/null +++ b/pkg/apis/lifecycle/install/install.go @@ -0,0 +1,38 @@ +/* +Copyright 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 lifecycle API group, 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/lifecycle" + "k8s.io/kubernetes/pkg/apis/lifecycle/v1alpha1" +) + +func init() { + Install(legacyscheme.Scheme) +} + +// Install registers the API group and adds types to a scheme +func Install(scheme *runtime.Scheme) { + utilruntime.Must(lifecycle.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(scheme.SetVersionPriority(v1alpha1.SchemeGroupVersion)) +} diff --git a/pkg/apis/lifecycle/register.go b/pkg/apis/lifecycle/register.go new file mode 100644 index 00000000000..75766f748da --- /dev/null +++ b/pkg/apis/lifecycle/register.go @@ -0,0 +1,56 @@ +/* +Copyright 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 lifecycle + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "lifecycle.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 points to a list of functions added to Scheme. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme applies all the stored functions to the scheme. + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Eviction{}, + &EvictionList{}, + &EvictionRequest{}, + &EvictionRequestList{}, + ) + return nil +} diff --git a/pkg/apis/lifecycle/types.go b/pkg/apis/lifecycle/types.go new file mode 100644 index 00000000000..aee2511485b --- /dev/null +++ b/pkg/apis/lifecycle/types.go @@ -0,0 +1,590 @@ +/* +Copyright 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 lifecycle + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachinerytypes "k8s.io/apimachinery/pkg/types" +) + +const ( + // EvictionResponderImperativeEviction is a default responder with a priority of 100 that will evict + // pods using the imperative Eviction API (pods//eviction subresource) with a backoff. + EvictionResponderImperativeEviction string = "imperative-eviction.k8s.io/evictor" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EvictionRequest defines a request that should ideally result in a graceful eviction of a +// .spec.target (e.g. termination of a pod). +// +// The evictionrequest-controller observes intents of all EvictionRequests and transforms them into +// Evictions. +// - .spec.requester is set as a label on the Eviction for easier lookup. +// - Each target can have a set of responders assigned to it. Eviction objects are observed by +// these responders, who implement the eviction logic and update the Eviction's status with +// progress. +// +// There is many-to-many relationship between EvictionRequests and Evictions in general. +// And many-to-one if the target is a pod. +// +// If all requesters withdraw their eviction intent for a common target, the eviction will be +// canceled. Deleting an EvictionRequest also counts as a withdrawal. +// Once all EvictionRequest of a target are removed, the corresponding Evictions are eventually +// garbage collected. +type EvictionRequest struct { + metav1.TypeMeta + + // metadata is the standard object metadata; More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata. + // +optional + metav1.ObjectMeta + + // spec defines the eviction request specification. + // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec EvictionRequestSpec + + // status represents the most recently observed status of the eviction request. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status EvictionRequestStatus +} + +// EvictionRequestSpec is a specification of an EvictionRequest. +type EvictionRequestSpec struct { + // target contains a reference to an object (e.g. a pod) that should be evicted. + // This field is required and immutable. + // +required + Target EvictionRequestTarget + + // requester allows you to identify the entity, that requested the eviction of the target. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // Domain names *.k8s.io and *.kubernetes.io are reserved. + // This field is required and immutable. + // +required + Requester string + + // intent specifies the action that should be taken for the specified target. + // + // - Eviction means that the requester is interested in the eviction of the target. + // - Withdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn for a common target, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + // +required + Intent EvictionRequestIntent +} + +// EvictionRequestTarget contains a reference to an object that should be evicted. +type EvictionRequestTarget struct { + // pod references a pod that is subject to eviction/termination. + // Pods that are part of a PodGroup (.spec.schedulingGroup is set) are not supported. + // +optional + Pod *EvictionRequestPodReference +} + +// EvictionRequestPodReference contains enough information to locate the referenced pod inside the +// same namespace. +type EvictionRequestPodReference struct { + // name of the target. + // This field is required. + // +required + Name string + // uid of the target. + // It can be found in .metadata.uid of the target and is a lowercase UUID in 8-4-4-4-12 format. + // This field is required. + // +required + UID apimachinerytypes.UID +} + +// EvictionRequestIntent specifies a requester intent. +type EvictionRequestIntent string + +// These are intents that can be set by each requester. +const ( + // EvictionRequestIntentEviction means that the requester is interested in the eviction of the target. + EvictionRequestIntentEviction EvictionRequestIntent = "Eviction" + + // EvictionRequestIntentWithdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn for a common target, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + EvictionRequestIntentWithdrawn EvictionRequestIntent = "Withdrawn" +) + +// EvictionRequestStatus represents the last observed status of the eviction request. +type EvictionRequestStatus struct { + // conditions contain information about the eviction request. + // + // EvictionRequest specific conditions are: TargetEvicted or Failed (managed by evictionrequest-controller). + // - Failed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + // - TargetEvicted means that the target has been evicted (e.g. a pod has been terminated or deleted). + // + // These conditions can be reset if the eviction was unsuccessful and a new Eviction intent has + // been submitted. + // + // The maximum length of the conditions list is 100. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition + + // observedGeneration is EvictionRequest's .metadata.generation observed by the evictionrequest-controller. + // The observed generation value cannot be negative and can only be incremented. + // The minimum value is 1. + // This field is managed by evictionrequest-controller. + // +optional + ObservedGeneration *int64 +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EvictionRequestList contains a list of EvictionRequests resources. +type EvictionRequestList struct { + metav1.TypeMeta + // metadata is the standard list metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ListMeta + + // items is the list of EvictionRequests. + Items []EvictionRequest +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Eviction initiates an eviction process, which should ideally result in a graceful eviction of a +// .spec.target (e.g. termination of a pod). +// +// The evictionrequest-controller observes intents of all EvictionRequests and transforms them into +// Evictions. It manages the Eviction lifecycle. +// Requesters are preserved in .status.requesters even after they have withdrawn their request. +// If all requesters withdraw their eviction intent for a common target, the eviction will be +// canceled. Once all EvictionRequest corresponding to this Eviction .spec.target have been +// removed, this Eviction object will eventually be garbage collected. +// +// If the target is a pod, the .status.targetResponders is populated from Pod's +// .spec.evictionResponders. +// +// Responders should observe and communicate through the .status to help with the eviction +// of the target when they see their state == Active in .status.targetResponders. ResponderStatus +// struct should then be periodically updated to indicate the progress or completion of the eviction +// process by each responder in .status.responders. If .status.responders[].heartbeatTime is not +// updated within the heartbeat deadline defined by the Eviction API (currently 20 minutes), the +// eviction is passed over to the next responder with a lower priority. +// +// If there are no other responders and the target is a pod, the last default +// imperative-eviction.k8s.io/evictor responder with a priority of 100 will evict the pod using the +// imperative Eviction API (pods//eviction subresource). +type Eviction struct { + metav1.TypeMeta + + // metadata is the standard object metadata; More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata. + // .metadata.name set by the evictionrequest-controller is purely informative and subject to change. + // .spec.target field should be used to identify the target precisesly. + // + // The requester and responder names will be used as label keys and added to the labels of the + // eviction in one of the following formats: + // 1. acme.io/foo: "requester" + // 2. acme.io/foo: "responder" + // 3. acme.io/foo: "requester-responder" + // + // Please see EvictionParticipantRole for available role label values. + // +optional + metav1.ObjectMeta + + // spec defines the eviction specification. + // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec EvictionSpec + + // status represents the most recently observed status of the eviction. + // Populated by responders and evictionrequest-controller. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status EvictionStatus +} + +// EvictionParticipantRole specifies a role of an eviction participant intent. +type EvictionParticipantRole string + +const ( + // EvictionParticipantRoleRequester identifies a requester which creates EvictionRequests. + EvictionParticipantRoleRequester EvictionParticipantRole = "requester" + + // EvictionParticipantRoleResponder identifies a responder which responds to an Eviction. + EvictionParticipantRoleResponder EvictionParticipantRole = "responder" + + // EvictionParticipantRoleRequesterResponder is both a "requester" and a "responder" at the same time. + EvictionParticipantRoleRequesterResponder EvictionParticipantRole = "requester-responder" +) + +// EvictionSpec is a specification of an Eviction. +type EvictionSpec struct { + // target contains a reference to an object (e.g. a pod) that should be evicted. + // This field is required and immutable. + // +required + Target EvictionTarget +} + +// EvictionTarget contains a reference to an object that should be evicted. +// +union +type EvictionTarget struct { + // pod references a pod that is subject to eviction/termination. + // Pods that are part of a PodGroup (.spec.schedulingGroup is set) are not supported. + // +optional + Pod *EvictionPodReference +} + +// EvictionPodReference contains enough information to locate the referenced pod inside the same +// namespace. +type EvictionPodReference struct { + // name of the target. + // This field is required. + // +required + Name string + // uid of the target. + // It can be found in .metadata.uid of the target and is a lowercase UUID in 8-4-4-4-12 format. + // This field is required. + // +required + UID apimachinerytypes.UID +} + +// EvictionStatus represents the last observed status of the eviction request. +type EvictionStatus struct { + // conditions contain information about the eviction request. + // + // Eviction specific conditions are: TargetEvicted or Failed (managed by evictionrequest-controller). + // - Failed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + // - TargetEvicted means that the target has been evicted (e.g. a pod has been terminated or deleted). + // + // The maximum length of the conditions list is 100. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition + + // observedGeneration is Eviction's .metadata.generation observed by the evictionrequest-controller. + // The observed generation value cannot be negative and can only be incremented. + // The minimum value is 1. + // This field is managed by evictionrequest-controller. + // +optional + ObservedGeneration *int64 + + // requesters allow you to identify the entities, that requested the eviction of the target. + // If all the requesters withdraw their eviction intent, the eviction will be canceled. + // + // The maximum length of the requesters list is 100. + // If this limit is exceeded, requesters with Withdrawn intent should be dropped first. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + Requesters []Requester + + // targetResponders reference responders that should eventually respond to this eviction + // to help with the graceful eviction of a target. These responders are selected sequentially, + // according to their specified priority by setting the Active state to the TargetResponder + // .state field. The maximum number of active responders allowed is 1. + // Eventually each responder can end up in an Interrupted, Canceled or, Completed state. + // Responders should observe these states in order to navigate their lifecycle. + // + // If the target is a pod, the field is populated from Pod's .spec.evictionResponders. Default + // responders may be added to the list according to the target. + // + // Default responders: + // - imperative-eviction.k8s.io/evictor responder with a priority of 100 is added to the list if the + // target is a pod. It will call the imperative Eviction API (pods//eviction subresource). + // This call may not succeed due to PodDisruptionBudgets, which may block the pod termination. + // It will update the responder message and try again with a backoff. + // + // The maximum length of the responders list is 11. + // The length and keys of the list cannot change once set. + // This field is managed by evictionrequest-controller. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + TargetResponders []TargetResponder + + // responders represents the eviction process status of each declared responder. + // + // The responder list should be the same length and have the same .name fields as + // .status.targetResponders. Only responders with .name that have Active state in + // .targetResponders[].state should be updated and can be mutated. First initialization + // of the list is allowed. + // + // Each ResponderStatus is initialized by evictionrequest-controller and then managed by + // the designated responder. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + Responders []ResponderStatus +} + +type EvictionConditionType string + +// These are built-in conditions of an eviction request. +const ( + // EvictionConditionFailed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + EvictionConditionFailed EvictionConditionType = "Failed" + + // EvictionConditionTargetEvicted means that the target has been evicted (e.g. a pod has been + // terminated or deleted). + EvictionConditionTargetEvicted EvictionConditionType = "TargetEvicted" +) + +type EvictionConditionReason string + +// These are built-in condition reasons of an eviction request. +const ( + // EvictionConditionReasonAwaitingEviction means that this Eviction works as expected and the target + // is scheduled for an eviction. + // This reason is set for the Failed and TargetEvicted condition. + EvictionConditionReasonAwaitingEviction EvictionConditionReason = "AwaitingEviction" + // EvictionConditionReasonEvictionInvalid means that the Eviction is not accepted because the + // initial configuration is not valid. + // This reason is set for the Failed condition. + EvictionConditionReasonEvictionInvalid EvictionConditionReason = "EvictionInvalid" + // EvictionConditionReasonCanceledDueToNoRequesters means that the Eviction is canceled because there is no + // EvictionRequest with the same target and Eviction intent in .spec.intent. + // This reason is set for the Failed condition. + EvictionConditionReasonCanceledDueToNoRequesters EvictionConditionReason = "CanceledDueToNoRequesters" + // EvictionConditionReasonSucceeded means that the Eviction has successfully evicted the target. + // This reason is set for the Failed condition. + EvictionConditionReasonSucceeded EvictionConditionReason = "Succeeded" + // EvictionConditionReasonNoFurtherResponder means that the Eviction responders failed to evict + // the target and that no further responder is available. + // This reason is set for the Failed condition. + EvictionConditionReasonNoFurtherResponder EvictionConditionReason = "NoFurtherResponder" + // EvictionConditionReasonPodDeleted means that the target pod has been deleted. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonPodDeleted EvictionConditionReason = "PodDeleted" + // EvictionConditionReasonPodTerminal means that the target pod has reached a terminal state. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonPodTerminal EvictionConditionReason = "PodTerminal" + // EvictionConditionReasonEvictionFailed means that the eviction of the target was unsuccessful. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonEvictionFailed EvictionConditionReason = "EvictionFailed" +) + +// Requester allows you to identify the entity, that requested the eviction of the target. +// +structType=atomic +type Requester struct { + // name allows you to identify the entity, that requested the eviction of the target. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field must be unique for each requester. + // This field is required. + // +required + Name string + + // intent specifies the action that should be taken for the specified target. + // + // - Eviction means that the requester is interested in the eviction of the target. + // - Withdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + // +required + Intent RequesterIntent +} + +// RequesterIntent specifies a requester intent. +type RequesterIntent string + +// These are intents that can be set by each requester. +const ( + // RequesterIntentEviction means that the requester is interested in the eviction of the target. + RequesterIntentEviction RequesterIntent = "Eviction" + + // RequesterIntentWithdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + RequesterIntentWithdrawn RequesterIntent = "Withdrawn" +) + +// TargetResponder allows you to specify the responder reacting to the Eviction. +// Responders should observe and communicate through the Eviction API (see .state) to help +// with the graceful eviction of a target (e.g. termination of a pod). +// +structType=atomic +type TargetResponder struct { + // name allows you to identify the responder reacting to the Eviction. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field must be unique for each responder. + // This field is required. + // +required + Name string + + // priority for this responder. Higher priorities are selected first by the evictionrequest-controller. + // If there are responders with the same priority, the responder whose domain name comes first in the + // alphabetical higher domain order, will be picked. This means that the top domain labels are compared + // alphabetically first, followed by the lower domain labels. The key is compared last. + // + // The responder that is the managing controller of the pod should set the value of + // this field to 10000 to allow both for preemption or fallback registration by other + // responders. + // + // The minimum value is 0 and the maximum value is 100000. + // The interval 0-999 is reserved for responders with *.k8s.io suffix. + // This field is required and immutable. + // +required + Priority *int32 + + // state specifies a state that is assigned by the evictionrequest-controller. Responders should observe + // this state in order to navigate their lifecycle. + // - Inactive means that the responder should not yet process this eviction request. + // - Active means that the responder is either running or expected to start soon. + // Also, startTime has been set in the ResponderStatus by the evictionrequest-controller. + // + // An active responder should currently interact with the eviction process by updating + // .status.responders, where .name is the active responder name. ResponderStatus fields + // should be periodically updated to indicate the progress or completion of the eviction process. + // If .status.responders[].heartbeatTime field is not updated within the heartbeat deadline defined + // by the Eviction API (currently 20 minutes), the eviction is passed over to the next responder + // with a lower priority. Only one responder can be active at a time. + // - Interrupted means that the responder has failed to start or failed to update + // heartbeatTime in ResponderStatus in a timely manner. + // - Canceled means that the responder has been canceled. In other words, there is no + // EvictionRequest with the same target and Eviction intent in .spec.intent. + // - Completed means that the responder has successfully completed and set completionTime + // in ResponderStatus. + // + // Please refer to the ResponderStatus in .status.responders for more details on each responder. + // +required + State ResponderStateType +} + +// ResponderStateType specifies a state that is assigned by the evictionrequest-controller. +type ResponderStateType string + +const ( + // ResponderStateInactive means that the responder should not yet process this eviction request. + ResponderStateInactive ResponderStateType = "Inactive" + + // ResponderStateActive means that the responder is either running or expected to start soon. + // Also, startTime has been set in the ResponderStatus by the evictionrequest-controller. + // + // An active responder should currently interact with the eviction process by updating + // .status.responders, where .name is the active responder name. ResponderStatus fields + // should be periodically updated to indicate the progress or completion of the eviction process. + // If .status.responders[].heartbeatTime field is not updated within the heartbeat deadline defined + // by the Eviction API (currently 20 minutes), the eviction is passed over to the next responder + // with a lower priority. Only one responder can be active at a time. + ResponderStateActive ResponderStateType = "Active" + + // ResponderStateInterrupted means that the responder has failed to start or failed to update + // heartbeatTime in ResponderStatus in a timely manner. + ResponderStateInterrupted ResponderStateType = "Interrupted" + + // ResponderStateCanceled means that the responder has been canceled. In other words, there + // is no EvictionRequest with the same target and Eviction intent in .spec.intent. + ResponderStateCanceled ResponderStateType = "Canceled" + + // ResponderStateCompleted means that the responder has successfully completed and set completionTime + // in ResponderStatus. + ResponderStateCompleted ResponderStateType = "Completed" +) + +// ResponderStatus represents the last observed status of the eviction process of the responder. +// It should be only updated by the designated responder whose name is .name field. +// +structType=granular +type ResponderStatus struct { + // name allows you to identify the responder reacting to the Eviction. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field is initialized by Kubernetes and must be unique for each responder. + // This field is required. + // +required + Name string + + // startTime tracks the time at which this responder was designated as active and should start + // processing the eviction request. + // It should reflect the present time when set. + // This field is initialized by Kubernetes when this responder becomes active. + // This field becomes immutable once set. + // +optional + StartTime *metav1.Time + + // heartbeatTime is the last time at which the eviction process was reported to be in progress + // by the responder. + // It should reflect the present time when set. + // Responders should avoid heartbeats more frequent than 20 seconds to avoid overloading the + // control-plane. + // +optional + HeartbeatTime *metav1.Time + + // expectedCompletionTime is the time at which the eviction process step is expected to end for the + // responder. + // The time cannot be set to the past. + // May be omitted if no estimate can be made. + // +optional + ExpectedCompletionTime *metav1.Time + + // completionTime tracks the time at which the Responder stopped processing the eviction request. + // Completion means that the responders has either fully or partially completed the + // eviction process, which may have resulted in target eviction (e.g. pod termination). + // It should reflect the present time when set. + // This field becomes immutable once set. + // +optional + CompletionTime *metav1.Time + + // message provides human-readable details about the state of the responder and the eviction + // process. + // Maximum length is 4000 characters. + // +optional + Message *string +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EvictionList contains a list of Eviction resources. +type EvictionList struct { + metav1.TypeMeta + // metadata is the standard list metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ListMeta + + // items is the list of Evictions. + Items []Eviction +} diff --git a/pkg/apis/lifecycle/v1alpha1/doc.go b/pkg/apis/lifecycle/v1alpha1/doc.go new file mode 100644 index 00000000000..b32baaf7621 --- /dev/null +++ b/pkg/apis/lifecycle/v1alpha1/doc.go @@ -0,0 +1,26 @@ +/* +Copyright 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/lifecycle +// +k8s:conversion-gen-external-types=k8s.io/api/lifecycle/v1alpha1 +// +k8s:defaulter-gen=TypeMeta +// +k8s:defaulter-gen-input=k8s.io/api/lifecycle/v1alpha1 +// +k8s:validation-gen=TypesWithField=TypeMeta +// +k8s:validation-gen-input=k8s.io/api/lifecycle/v1alpha1 + +// +groupName=lifecycle.k8s.io + +package v1alpha1 diff --git a/pkg/apis/lifecycle/v1alpha1/register.go b/pkg/apis/lifecycle/v1alpha1/register.go new file mode 100644 index 00000000000..a4b74b5bc91 --- /dev/null +++ b/pkg/apis/lifecycle/v1alpha1/register.go @@ -0,0 +1,46 @@ +/* +Copyright 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 ( + lifecyclev1alpha1 "k8s.io/api/lifecycle/v1alpha1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName is the group name use in this package +const GroupName = "lifecycle.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 ( + localSchemeBuilder = &lifecyclev1alpha1.SchemeBuilder + // AddToScheme is a common registration function for mapping packaged scoped group & version keys to a scheme + 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(RegisterDefaults) +} diff --git a/pkg/apis/lifecycle/v1alpha1/validation.go b/pkg/apis/lifecycle/v1alpha1/validation.go new file mode 100644 index 00000000000..6948238665c --- /dev/null +++ b/pkg/apis/lifecycle/v1alpha1/validation.go @@ -0,0 +1,37 @@ +/* +Copyright 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 ( + "context" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" + apiscorev1 "k8s.io/kubernetes/pkg/apis/core/v1" +) + +// ValidateCustom_EvictionRequestSpec_Requester is wired into the generated declarative validation by +// +k8s:customValidation on corev1.EvictionRequestSpec.Requester. It enforces that the +// k8s-prefixed-label-key value, is not prefixed with a k8s.io or kubernetes.io domain. +func ValidateCustom_EvictionRequestSpec_Requester(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList { + if value != nil { + // Once, we have k8s requesters, we need to check a set of supported requesters that can bypass this validation. + return apiscorev1.ValidateForbiddenReservedDomainSuffixes(fldPath, *value, []string{".k8s.io", ".kubernetes.io"}) + + } + return nil +} diff --git a/pkg/apis/lifecycle/validation/validation.go b/pkg/apis/lifecycle/validation/validation.go new file mode 100644 index 00000000000..bf3f2e29df1 --- /dev/null +++ b/pkg/apis/lifecycle/validation/validation.go @@ -0,0 +1,616 @@ +/* +Copyright 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 ( + "context" + "fmt" + "slices" + "strings" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/api/validate" + "k8s.io/apimachinery/pkg/api/validation" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + validation2 "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" + apivalidation "k8s.io/kubernetes/pkg/apis/core/validation" + "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/utils/clock" + "k8s.io/utils/ptr" +) + +const defaultEvictionResponderCount = 1 // EvictionResponderImperativeEviction +// more than .pod.spec.evictionResponders to account for default responders +const maxEvictionResponders = apivalidation.MaxPodEvictionResponders + defaultEvictionResponderCount + +// ValidateEvictionRequest validates an EvictionRequest. +func ValidateEvictionRequest(evictionRequest *lifecycle.EvictionRequest) field.ErrorList { + validateLongName := func(fldPath *field.Path, name string) field.ErrorList { + return validate.LongName(context.Background(), operation.Operation{}, fldPath, &name, nil).MarkCoveredByDeclarative() + } + allErrs := apivalidation.ValidateObjectMetaWithOpts(&evictionRequest.ObjectMeta, true, validateLongName, field.NewPath("metadata")) + // Spec validation is covered by DV (Validate_EvictionRequestSpec) + return allErrs +} + +// ValidateEvictionRequestUpdate validates an EvictionRequest. +func ValidateEvictionRequestUpdate(evictionRequest, oldEvictionRequest *lifecycle.EvictionRequest) field.ErrorList { + allErrs := apivalidation.ValidateObjectMetaUpdate(&evictionRequest.ObjectMeta, &oldEvictionRequest.ObjectMeta, field.NewPath("metadata")) + // Spec validation is covered by DV (Validate_EvictionRequestSpec) + return allErrs +} + +// ValidateEvictionRequestStatusUpdate validates an EvictionRequest Status update. +func ValidateEvictionRequestStatusUpdate(evictionRequest, oldEvictionRequest *lifecycle.EvictionRequest) field.ErrorList { + allErrs := apivalidation.ValidateObjectMetaUpdate(&evictionRequest.ObjectMeta, &oldEvictionRequest.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, ValidateEvictionRequestStatus(&evictionRequest.Status, &oldEvictionRequest.Status, field.NewPath("status"))...) + return allErrs +} + +// ValidateEvictionRequestStatus validates an EvictionRequest Status. +func ValidateEvictionRequestStatus(status, oldStatus *lifecycle.EvictionRequestStatus, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // observedGeneration covered by DV + + // conditions + conditionsPath := fldPath.Child("conditions") + // conditions validation covered by DV + allErrs = append(allErrs, validation2.ValidateConditions(status.Conditions, conditionsPath)...) + + return allErrs +} + +// ValidateEviction validates an Eviction. +func ValidateEviction(eviction *lifecycle.Eviction) field.ErrorList { + validateLongName := func(fldPath *field.Path, name string) field.ErrorList { + return validate.LongName(context.Background(), operation.Operation{}, fldPath, &name, nil).MarkCoveredByDeclarative() + } + allErrs := apivalidation.ValidateObjectMetaWithOpts(&eviction.ObjectMeta, true, validateLongName, field.NewPath("metadata")) + // Spec validation is covered by DV (Validate_EvictionSpec) + return allErrs +} + +// ValidateEvictionUpdate validates an Eviction. +func ValidateEvictionUpdate(eviction, oldEviction *lifecycle.Eviction) field.ErrorList { + allErrs := apivalidation.ValidateObjectMetaUpdate(&eviction.ObjectMeta, &oldEviction.ObjectMeta, field.NewPath("metadata")) + // Spec validation is covered by DV (Validate_EvictionSpec) + return allErrs +} + +type EvictionStatusValidationOptions struct { + Clock clock.PassiveClock +} + +// ValidateEvictionStatusUpdate validates an Eviction Status update. +func ValidateEvictionStatusUpdate(eviction, oldEviction *lifecycle.Eviction, opts EvictionStatusValidationOptions) field.ErrorList { + allErrs := apivalidation.ValidateObjectMetaUpdate(&eviction.ObjectMeta, &oldEviction.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, ValidateEvictionStatus(&eviction.Status, &oldEviction.Status, field.NewPath("status"), opts)...) + return allErrs +} + +// ValidateEvictionStatus validates an Eviction Status. +func ValidateEvictionStatus(status, oldStatus *lifecycle.EvictionStatus, fldPath *field.Path, opts EvictionStatusValidationOptions) field.ErrorList { + var allErrs field.ErrorList + + // observedGeneration covered by DV + + // conditions + conditionsPath := fldPath.Child("conditions") + // conditions validation covered by DV + allErrs = append(allErrs, validation2.ValidateConditions(status.Conditions, conditionsPath)...) + isEvicted := meta.IsStatusConditionTrue(status.Conditions, string(lifecycle.EvictionConditionTargetEvicted)) + isFailed := meta.IsStatusConditionTrue(status.Conditions, string(lifecycle.EvictionConditionFailed)) + for _, oldCondition := range oldStatus.Conditions { + if oldCondition.Type == string(lifecycle.EvictionConditionTargetEvicted) || oldCondition.Type == string(lifecycle.EvictionConditionFailed) { + newCondition := meta.FindStatusCondition(status.Conditions, oldCondition.Type) + if oldCondition.Status == v1.ConditionTrue && (newCondition == nil || newCondition.Status == v1.ConditionFalse || oldCondition.Reason != newCondition.Reason) { + allErrs = append(allErrs, field.Invalid(conditionsPath, status.Conditions, fmt.Sprintf("%s condition status cannot be reverted", oldCondition.Type))) + if oldCondition.Type == string(lifecycle.EvictionConditionTargetEvicted) { + isEvicted = true // do not use invalid condition state for next validations + } + if oldCondition.Type == string(lifecycle.EvictionConditionFailed) { + isFailed = true // do not use invalid condition state for next validations + } + } + } + } + + // requesters covered by DV (Validate_EvictionStatus) + + // targetResponders, and responders + allErrs = append(allErrs, ValidateAllEvictionStatusResponderFields(status, oldStatus, fldPath, EvictionStatusRespondersValidationOptions{ + Clock: opts.Clock, + IsEvicted: isEvicted, + IsFailed: isFailed, + })...) + + return allErrs +} + +type EvictionStatusRespondersValidationOptions struct { + Clock clock.PassiveClock + IsEvicted bool + IsFailed bool +} + +// ValidateAllEvictionStatusResponderFields validates .status.targetResponders and .status.responders +// +// Multiple actors are expected to update the status (eviction-controller, responders and requesters updating conditions). +// We have to emulate the eviction-controller behavior, to prevent invalid updates by other misbehaving/malicious actors. +// 1. .status.targetResponders and .status.responders should be set first. +// 2. Responders' state transitions from Inactive to Active, and from Active to Interrupted, Canceled or, Complete +// gradually in the order of the responder priorities This is not reversible. +// 3. Responders' state transition from Active to Interrupted, Canceled or, Complete when +// .status.responders[].completionTime is set or when heartbeat is exceeded. This is not reversible. +// 4. .status.responders items cannot be removed once set. Only active responders can mutate it. +// 5. The controller can mark an Eviction as Evicted or Canceled via conditions. We then have to allow removal +// from activeResponders and prevent any new ones. +func ValidateAllEvictionStatusResponderFields(status, oldStatus *lifecycle.EvictionStatus, fldPath *field.Path, opts EvictionStatusRespondersValidationOptions) field.ErrorList { + var allErrs field.ErrorList + heartbeatDeadline := time.Minute * 20 + allowedTimeSkew := time.Second * 30 + allowedMaxExpectedCompletionTime := time.Hour * 24 * 365 * 10 // 10 years + + statusResponders := append(make([]lifecycle.ResponderStatus, 0, len(status.Responders)), status.Responders...) + + // targetResponders + allErrs = append(allErrs, ValidateEvictionTargetResponders(status.TargetResponders, oldStatus.TargetResponders, fldPath.Child("targetResponders"), ValidateEvictionTargetRespondersOptions{ + Clock: opts.Clock, + IsFailed: opts.IsFailed, + IsEvicted: opts.IsEvicted, + StatusResponders: statusResponders, + HeartbeatDeadline: heartbeatDeadline, + AllowedTimeSkew: allowedTimeSkew, + })...) + + // responders + allErrs = append(allErrs, ValidateEvictionStatusResponders(status.Responders, oldStatus.Responders, fldPath.Child("responders"), ValidateEvictionStatusRespondersOptions{ + Clock: opts.Clock, + TargetResponders: status.TargetResponders, + AllowedTimeSkew: allowedTimeSkew, + MaxExpectedCompletionTime: allowedMaxExpectedCompletionTime, + })...) + + return allErrs +} + +type ValidateEvictionTargetRespondersOptions struct { + Clock clock.PassiveClock + IsEvicted bool + IsFailed bool + StatusResponders []lifecycle.ResponderStatus + HeartbeatDeadline time.Duration + AllowedTimeSkew time.Duration +} + +func ValidateEvictionTargetResponders(targetResponders, oldTargetResponders []lifecycle.TargetResponder, fldPath *field.Path, opts ValidateEvictionTargetRespondersOptions) field.ErrorList { + var allErrs field.ErrorList + if len(oldTargetResponders) != 0 { + if len(targetResponders) != len(oldTargetResponders) { + return append(allErrs, field.Invalid(fldPath, targetResponders, "must preserve the same length and the same keys")) + } + targetRespondersNames := sets.New[string]() + for _, responder := range targetResponders { + targetRespondersNames.Insert(responder.Name) + } + for _, oldTargetResponder := range oldTargetResponders { + if !targetRespondersNames.Has(oldTargetResponder.Name) { + return append(allErrs, field.Invalid(fldPath, targetResponders, "must preserve the same keys")) + } + } + } + if len(targetResponders) == 0 { + // not initialized yet + return allErrs + } + + if len(targetResponders) > maxEvictionResponders { + // covered by declarative validation; exit early + return allErrs + } + + // simulate declarative error for early exit + uniqueErrors := validate.ValSliceUnique(context.TODO(), operation.Operation{}, fldPath, targetResponders, nil, + func(a *lifecycle.TargetResponder, b *lifecycle.TargetResponder) bool { return a.Name == b.Name }) + if len(uniqueErrors) > 0 { + // does not make sense to check for state transition with duplicates + return allErrs + } + statusResponders := map[string]*lifecycle.ResponderStatus{} + for _, responder := range opts.StatusResponders { + statusResponders[responder.Name] = &responder + } + targetRespondersOriginalIdx := map[string]int{} + for i, responder := range targetResponders { + targetRespondersOriginalIdx[responder.Name] = i + } + + // Sort responders first, then use ordered progression to simplify the processing. + // This means lower indexes are Activated first, and the last index is processed last. + sortedOldTargetResponders := append([]lifecycle.TargetResponder{}, oldTargetResponders...) + SortTargetResponders(sortedOldTargetResponders) + + sortedTargetResponders := append([]lifecycle.TargetResponder{}, targetResponders...) + SortTargetResponders(sortedTargetResponders) + + lastActiveIdx := -1 + for i, responder := range sortedOldTargetResponders { + if responder.State == lifecycle.ResponderStateActive { + lastActiveIdx = i + break + } + } + hasNeverBeenActive := len(sortedOldTargetResponders) == 0 // we should Activate during the first sync + hasFoundLastActive := lastActiveIdx != -1 + activeChanged := hasFoundLastActive && sortedTargetResponders[lastActiveIdx].State != lifecycle.ResponderStateActive + isFinal := opts.IsEvicted || opts.IsFailed + + lastOldResponderState := lifecycle.ResponderStateInactive + if len(sortedOldTargetResponders) > 0 { + lastOldResponderState = sortedOldTargetResponders[len(sortedOldTargetResponders)-1].State + } + isFinalOrAllProcessed := isFinal || lastOldResponderState == lifecycle.ResponderStateCompleted || + lastOldResponderState == lifecycle.ResponderStateCanceled || + lastOldResponderState == lifecycle.ResponderStateInterrupted + + for i, responder := range sortedTargetResponders { + expectedStates := sets.New[lifecycle.ResponderStateType]() + + expectedStatesReason := "" + switch { + // + case i < lastActiveIdx || + (len(sortedOldTargetResponders) > 0 && !hasFoundLastActive && isFinalOrAllProcessed): + // Processed responders must preserve their state. + expectedStates.Insert(sortedOldTargetResponders[i].State) + expectedStatesReason = "final state is immutable" + case i == lastActiveIdx && activeChanged: + // If Active responder changes, it must have a final state. + expectedStates.Insert(lifecycle.ResponderStateInterrupted, + lifecycle.ResponderStateCanceled, + lifecycle.ResponderStateCompleted) + expectedStatesReason = "this responder must reach a final state" + case i == lastActiveIdx: + // Unchanged Active responders can stay Active. + expectedStates.Insert(lifecycle.ResponderStateActive) + expectedStatesReason = "the eviction request stays active" + if isFinal { + // Last Responder must finish before setting the final condition. + expectedStates.Clear().Insert(lifecycle.ResponderStateInterrupted, + lifecycle.ResponderStateCanceled, + lifecycle.ResponderStateCompleted) + expectedStatesReason = "the eviction request has finished processing" + } + case i == 0 && hasNeverBeenActive, + i == lastActiveIdx+1 && activeChanged: + // Next responder must move to an Active state, when the old one is final. + expectedStates.Insert(lifecycle.ResponderStateActive) + expectedStatesReason = "this responder is next in line" + if isFinal { + // Do not active next responder if we have finished. + expectedStates.Clear().Insert(lifecycle.ResponderStateInactive) + expectedStatesReason = "the eviction request has finished processing" + } + default: + expectedStates.Insert(lifecycle.ResponderStateInactive) + expectedStatesReason = "the previously active has not finished processing" + } + + allErrs = append(allErrs, ValidateTargetResponder(responder, fldPath.Index(targetRespondersOriginalIdx[responder.Name]), ValidateEvictionTargetResponderOptions{ + Clock: opts.Clock, + IsEvicted: opts.IsEvicted, + IsFailed: opts.IsFailed, + responderStatus: statusResponders[responder.Name], + expectedStates: expectedStates, + expectedStatesReason: expectedStatesReason, + HeartbeatDeadline: opts.HeartbeatDeadline, + AllowedTimeSkew: opts.AllowedTimeSkew, + })...) + } + return allErrs +} + +type ValidateEvictionTargetResponderOptions struct { + Clock clock.PassiveClock + IsEvicted bool + IsFailed bool + responderStatus *lifecycle.ResponderStatus + expectedStates sets.Set[lifecycle.ResponderStateType] + expectedStatesReason string + HeartbeatDeadline time.Duration + AllowedTimeSkew time.Duration +} + +func ValidateTargetResponder(evictionResponder lifecycle.TargetResponder, fldPath *field.Path, opts ValidateEvictionTargetResponderOptions) field.ErrorList { + var allErrs field.ErrorList + + // name covered by DV + + statusResponderPath := field.NewPath("status", "responders") + if opts.responderStatus == nil { + msg := fmt.Sprintf("%q has to be tracked in %s first", evictionResponder.Name, statusResponderPath) + return append(allErrs, field.Invalid(fldPath, evictionResponder, msg)) + } + + // priority + const reservedKubernetesRespondersPriority = 1000 + priorityAssignedToK8sResponders := map[string]int32{ + lifecycle.EvictionResponderImperativeEviction: 100, + } + priorityPath := fldPath.Child("priority") + // validate.Maximum covered by DV + // validate.Minimum covered by DV + if evictionResponder.Priority != nil && *evictionResponder.Priority >= 0 { + if expectedPriority, ok := priorityAssignedToK8sResponders[evictionResponder.Name]; ok { + if *evictionResponder.Priority != expectedPriority { + allErrs = append(allErrs, field.Invalid(priorityPath, *evictionResponder.Priority, fmt.Sprintf("core k8s responder is expected to have priority: %d", expectedPriority))) + } + } else if *evictionResponder.Priority < reservedKubernetesRespondersPriority { + // Each core responder should have priority assigned - see priorityAssignedToK8sResponders + allErrs = append(allErrs, field.Invalid(priorityPath, *evictionResponder.Priority, "priorities 0-999 are reserved for responders with *.k8s.io suffix")) + } + } + + // state + statePath := fldPath.Child("state") + // validate.RequiredValue covered by DV + // validate.Enum covered by DV + + // check that the state transition is allowed + if !opts.expectedStates.Has(evictionResponder.State) { + var expectedValues []string + for _, ev := range sets.List(opts.expectedStates) { + expectedValues = append(expectedValues, string(ev)) + } + msg := fmt.Sprintf("must be one of: %s", strings.Join(expectedValues, ", ")) + if len(opts.expectedStatesReason) > 0 { + msg = fmt.Sprintf("%s, because %s", msg, opts.expectedStatesReason) + } + allErrs = append(allErrs, field.Invalid(statePath, evictionResponder.State, msg)) + // short circuit as the validations below depend on a correct state + return allErrs + } + + // must be present in status responders + + // check the heartbeat deadline if the responder is not active anymore (.status.responders) + // we can skip the check if the Eviction is final (failed or evicted). + // StartTime presence is validated in ValidateEvictionStatusResponder + if evictionResponder.State != lifecycle.ResponderStateActive && opts.responderStatus.StartTime != nil && + opts.responderStatus.CompletionTime == nil && !opts.IsEvicted && !opts.IsFailed { + heartbeat := opts.responderStatus.StartTime + if opts.responderStatus.HeartbeatTime != nil { + heartbeat = opts.responderStatus.HeartbeatTime + } + if opts.Clock.Now().Before(heartbeat.Add(opts.HeartbeatDeadline).Add(-opts.AllowedTimeSkew)) { + msg := fmt.Sprintf("must stay Active because the responder is in progress and it should report %s or %s", statusResponderPath.Child("heartbeatTime"), statusResponderPath.Child("completionTime")) + allErrs = append(allErrs, field.Forbidden(statePath, msg)) + } + } + if evictionResponder.State == lifecycle.ResponderStateCompleted && opts.responderStatus.CompletionTime == nil { + msg := fmt.Sprintf("cannot become Completed because the responder didn't report %s", statusResponderPath.Child("completionTime")) + allErrs = append(allErrs, field.Invalid(statePath, evictionResponder.State, msg)) + } + return allErrs +} + +type ValidateEvictionStatusRespondersOptions struct { + Clock clock.PassiveClock + TargetResponders []lifecycle.TargetResponder + AllowedTimeSkew time.Duration + MaxExpectedCompletionTime time.Duration +} + +func ValidateEvictionStatusResponders(statusResponders, oldStatusResponders []lifecycle.ResponderStatus, fldPath *field.Path, opts ValidateEvictionStatusRespondersOptions) field.ErrorList { + var allErrs field.ErrorList + + if len(opts.TargetResponders) != len(statusResponders) { + return append(allErrs, field.Invalid(fldPath, statusResponders, "must be the same length as status.targetResponders and contain the same keys")) + } + statusRespondersNames := sets.New[string]() + for _, responder := range statusResponders { + statusRespondersNames.Insert(responder.Name) + } + targetResponders := map[string]*lifecycle.TargetResponder{} + for i, targetResponder := range opts.TargetResponders { + targetResponders[targetResponder.Name] = &opts.TargetResponders[i] + if !statusRespondersNames.Has(targetResponder.Name) { + return append(allErrs, field.Invalid(fldPath, statusResponders, "must contain the same keys as status.targetResponders")) + } + } + if len(statusResponders) == 0 { + // statusResponders and TargetResponders are not initialized yet + return allErrs + } + + if len(statusResponders) > maxEvictionResponders { + // TooMany is handled by declarative validation - detect early return + return allErrs + } + // validate.ValSliceUnique covered by DV - simulate an error + uniqueErrors := validate.ValSliceUnique(context.TODO(), operation.Operation{}, fldPath, statusResponders, nil, + func(a *lifecycle.ResponderStatus, b *lifecycle.ResponderStatus) bool { return a.Name == b.Name }) + if len(uniqueErrors) > 0 { + // does not make sense to check each responder status with duplicates as we depend on the target responder and an oldStatusResponder + return allErrs + } + + oldStatusRespondersMap := map[string]*lifecycle.ResponderStatus{} + for i := range oldStatusResponders { + oldStatusRespondersMap[oldStatusResponders[i].Name] = &oldStatusResponders[i] // +k8s:verify-mutation:reason=clone + } + for i, responder := range statusResponders { + targetResponder := targetResponders[responder.Name] // key presence checked above + responderPath := fldPath.Index(i) + allErrs = append(allErrs, ValidateEvictionStatusResponder(&responder, oldStatusRespondersMap[responder.Name], responderPath, + EvictionStatusResponderValidationOptions{ + Clock: opts.Clock, + responderState: targetResponder.State, + AllowedTimeSkew: opts.AllowedTimeSkew, + MaxExpectedCompletionTime: opts.MaxExpectedCompletionTime, + }, + )...) + } + return allErrs +} + +type EvictionStatusResponderValidationOptions struct { + responderState lifecycle.ResponderStateType + AllowedTimeSkew time.Duration + MaxExpectedCompletionTime time.Duration + Clock clock.PassiveClock +} + +func ValidateEvictionStatusResponder(status, oldStatus *lifecycle.ResponderStatus, fldPath *field.Path, opts EvictionStatusResponderValidationOptions) field.ErrorList { + var allErrs field.ErrorList + oldDefaultedStatus := lifecycle.ResponderStatus{} + if oldStatus != nil { + oldDefaultedStatus = *oldStatus // +k8s:verify-mutation:reason=clone + } + + if oldStatus != nil && !validate.SemanticDeepEqual(status, oldStatus) && opts.responderState != lifecycle.ResponderStateActive { + // immutable; changes to the ResponderStatus are only allowed by the active responder or during initialization + return append(allErrs, field.Invalid(fldPath, status, validation.FieldImmutableErrorMsg).WithOrigin("immutable")) + } + + // name covered by DV + // The existence of targetResponder with the same name is done in ValidateEvictionStatusResponders + + // startTime + startTimePath := fldPath.Child("startTime") + // immutable once set + if oldDefaultedStatus.StartTime != nil && !oldDefaultedStatus.StartTime.Equal(status.StartTime) { + // validate.NoUnset and validate.NoModify checks covered by DV + } else if status.StartTime == nil && opts.responderState == lifecycle.ResponderStateActive { + allErrs = append(allErrs, field.Required(startTimePath, "is required for an active responder")) + } else if status.StartTime != nil && !oldDefaultedStatus.StartTime.Equal(status.StartTime) && !timeNear(status.StartTime.Time, opts.Clock.Now(), opts.AllowedTimeSkew) { + allErrs = append(allErrs, field.Invalid(startTimePath, status.StartTime, "must be set to the present time")) + } + + // heartbeatTime + heartbeatTimePath := fldPath.Child("heartbeatTime") + if oldDefaultedStatus.HeartbeatTime != nil && status.HeartbeatTime == nil { + allErrs = append(allErrs, field.Required(heartbeatTimePath, "is required once set")) + } + if !oldDefaultedStatus.HeartbeatTime.Equal(status.HeartbeatTime) && status.HeartbeatTime != nil { + if status.StartTime == nil { + allErrs = append(allErrs, field.Invalid(heartbeatTimePath, status.HeartbeatTime, fmt.Sprintf("cannot be set before %s is set", startTimePath.String()))) + } else if status.HeartbeatTime.Before(oldDefaultedStatus.HeartbeatTime) { + // this could still happen since we allow for the skew + allErrs = append(allErrs, field.Invalid(heartbeatTimePath, status.HeartbeatTime, "cannot be decreased")) + } else if status.HeartbeatTime.Before(status.StartTime) { + // this could still happen since we allow for the skew + allErrs = append(allErrs, field.Invalid(heartbeatTimePath, status.HeartbeatTime, fmt.Sprintf("must occur after %s", startTimePath.String()))) + } else if !timeNear(status.HeartbeatTime.Time, opts.Clock.Now(), opts.AllowedTimeSkew) { + allErrs = append(allErrs, field.Invalid(heartbeatTimePath, status.HeartbeatTime, "must be set to the present time")) + } + } + + // expectedCompletionTime + expectedCompletionTimePath := fldPath.Child("expectedCompletionTime") + if !oldDefaultedStatus.ExpectedCompletionTime.Equal(status.ExpectedCompletionTime) && status.ExpectedCompletionTime != nil { + if status.StartTime == nil { + allErrs = append(allErrs, field.Invalid(expectedCompletionTimePath, status.ExpectedCompletionTime, fmt.Sprintf("cannot be set before %s is set", startTimePath.String()))) + } else if status.ExpectedCompletionTime.Before(status.StartTime) { + // this could still happen since we allow for the skew + allErrs = append(allErrs, field.Invalid(expectedCompletionTimePath, status.ExpectedCompletionTime, fmt.Sprintf("must occur after %s", startTimePath.String()))) + } else if status.ExpectedCompletionTime.Time.Before(opts.Clock.Now().Add(-opts.AllowedTimeSkew)) { + allErrs = append(allErrs, field.Invalid(expectedCompletionTimePath, status.ExpectedCompletionTime, "cannot be set to the past time")) + } else if status.ExpectedCompletionTime.Time.After(opts.Clock.Now().Add(opts.MaxExpectedCompletionTime)) { + allErrs = append(allErrs, field.Invalid(expectedCompletionTimePath, status.ExpectedCompletionTime, "must complete within 10 years")) // sanity check + } + } + + // completionTime + completionTimePath := fldPath.Child("completionTime") + // immutable once set + if oldDefaultedStatus.CompletionTime != nil && !oldDefaultedStatus.CompletionTime.Equal(status.CompletionTime) { + // validate.NoUnset and validate.NoModify checks covered by DV + } else if status.CompletionTime != nil { + if status.StartTime == nil { + allErrs = append(allErrs, field.Invalid(completionTimePath, status.CompletionTime, fmt.Sprintf("cannot be set before %s is set", startTimePath.String()))) + } else if status.CompletionTime.Before(status.StartTime) { + allErrs = append(allErrs, field.Invalid(completionTimePath, status.CompletionTime, fmt.Sprintf("must occur after %s", startTimePath.String()))) + } else if !oldDefaultedStatus.CompletionTime.Equal(status.CompletionTime) && !timeNear(status.CompletionTime.Time, opts.Clock.Now(), opts.AllowedTimeSkew) { + allErrs = append(allErrs, field.Invalid(completionTimePath, status.CompletionTime, "must be set to the present time")) + } + } + + // message covered by DV + + return allErrs +} + +func timeNear(a, b time.Time, skew time.Duration) bool { + return a.After(b.Add(-skew)) && a.Before(b.Add(skew)) +} + +// SortTargetResponders returns highest priority responders on a lower indes. +// If there are responders with the same priority, the responder whose domain name comes first in the +// alphabetical higher domain order, will be picked. This means that the top domain labels are compared +// alphabetically first, followed by the lower domain labels. The key is compared last. +func SortTargetResponders(responders []lifecycle.TargetResponder) { + getComponents := func(domainPrefixedKey string) ([]string, string) { + segments := strings.SplitN(domainPrefixedKey, "/", 2) + if len(segments) == 0 { + return nil, "" + } + var domainComponents []string + var key string + if len(segments) == 2 { + key = segments[1] + } + domainComponents = strings.Split(segments[0], ".") + slices.Reverse(domainComponents) + return domainComponents, key + } + slices.SortFunc(responders, func(a, b lifecycle.TargetResponder) int { + if !ptr.Equal(a.Priority, b.Priority) { + if a.Priority == nil { + return 1 + } + if b.Priority == nil { + return -1 + } + return int(ptr.Deref(b.Priority, -1) - ptr.Deref(a.Priority, -1)) + } + aComponents, aKey := getComponents(a.Name) + bComponents, bKey := getComponents(b.Name) + + for i, aComponent := range aComponents { + if i > len(bComponents)-1 { + // b has a higher priority if no component left for b + return 1 + } + bComponent := bComponents[i] + cmp := strings.Compare(aComponent, bComponent) + if cmp != 0 { + return cmp + } + } + if len(bComponents) > len(aComponents) { + // a has a higher priority if no component left for a + return -1 + } + return strings.Compare(aKey, bKey) + }) +} diff --git a/pkg/apis/lifecycle/validation/validation_test.go b/pkg/apis/lifecycle/validation/validation_test.go new file mode 100644 index 00000000000..d9e158d853c --- /dev/null +++ b/pkg/apis/lifecycle/validation/validation_test.go @@ -0,0 +1,1993 @@ +/* +Copyright 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" + "math/rand" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/api/validate" + "k8s.io/apimachinery/pkg/api/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/lifecycle" + utilsclock "k8s.io/utils/clock" + testing2 "k8s.io/utils/clock/testing" +) + +const validUID = "bd23f542-ac79-4b44-a628-9735e18b8037" + +func TestValidateEvictionRequest(t *testing.T) { + successCases := map[string]struct { + input *lifecycle.EvictionRequest + }{ + "valid: pod target": { + input: mkValidEvictionRequest(), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + errs := ValidateEvictionRequest(tc.input) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + } + }) + } + + failureCases := map[string]struct { + input *lifecycle.EvictionRequest + + errors []*field.Error + }{ + "missing namespace": { + input: mkValidEvictionRequest(setERNamespace("")), + errors: []*field.Error{ + field.Required(field.NewPath("metadata", "namespace"), ""), + }, + }, + } + + for name, tc := range failureCases { + t.Run(name, func(t *testing.T) { + errs := ValidateEvictionRequest(tc.input) + if len(errs) == 0 { + t.Errorf("Expected failure") + return + } + if len(errs) != len(tc.errors) { + t.Errorf("Expected %d errors, got %d: %v", len(tc.errors), len(errs), errs) + return + } + matcher := field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring() + matcher.Test(t, tc.errors, errs) + + for i, err := range errs { + expectedErr := tc.errors[i] + if err.CoveredByDeclarative != expectedErr.CoveredByDeclarative { + t.Errorf("Error %d: expected CoveredByDeclarative=%v, got %v for error: %v", + i, expectedErr.CoveredByDeclarative, err.CoveredByDeclarative, err) + } + } + }) + } +} + +func TestValidateEvictionRequestUpdate(t *testing.T) { + successCases := map[string]struct { + input *lifecycle.EvictionRequest + oldInput *lifecycle.EvictionRequest + }{ + "withdraw a requester intent": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setRequesterIntent(lifecycle.EvictionRequestIntentWithdrawn)), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + tc.oldInput.ResourceVersion = "0" + tc.input.ResourceVersion = "1" + errs := ValidateEvictionRequestUpdate(tc.input, tc.oldInput) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + return + } + }) + } +} + +func TestValidateEvictionRequestStatusUpdate(t *testing.T) { + clock := testing2.NewFakePassiveClock(time.Now()) + + successCases := map[string]struct { + clock utilsclock.PassiveClock + input *lifecycle.EvictionRequestStatus + oldInput *lifecycle.EvictionRequestStatus + }{ + // conditions + // this is different from Eviction + "Evicted condition can be reverted to false": { + oldInput: mkValidEvictionRequestStatus(addERCondition(clock, lifecycle.EvictionConditionTargetEvicted, true)), + input: mkValidEvictionRequestStatus(addERCondition(clock, lifecycle.EvictionConditionTargetEvicted, false)), + }, + // this is different from Eviction + "Failed condition can be reverted to false": { + oldInput: mkValidEvictionRequestStatus(addERCondition(clock, lifecycle.EvictionConditionFailed, true)), + input: mkValidEvictionRequestStatus(addERCondition(clock, lifecycle.EvictionConditionFailed, false)), + }, + // observedGeneration + "set initial generation": { + oldInput: &lifecycle.EvictionRequestStatus{}, + input: mkValidEvictionRequestStatus(), + }, + "update generation": { + oldInput: mkValidEvictionRequestStatus(), + input: mkValidEvictionRequestStatus(setERObservedGeneration(new(int64(5)))), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + if validate.SemanticDeepEqual(tc.oldInput, tc.input) { + t.Errorf("Expected oldInput and input to differ") + } + oldEvictionRequest := mkValidEvictionRequest() + oldEvictionRequest.ResourceVersion = "0" + oldEvictionRequest.Status = *tc.oldInput + evictionRequest := mkValidEvictionRequest() + evictionRequest.ResourceVersion = "1" + evictionRequest.Status = *tc.input + errs := ValidateEvictionRequestStatusUpdate(evictionRequest, oldEvictionRequest) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + } + }) + } + + failureCases := map[string]struct { + input *lifecycle.EvictionRequestStatus + oldInput *lifecycle.EvictionRequestStatus + + errors []*field.Error + }{ + // conditions + "add invalid condition": { + oldInput: mkValidEvictionRequestStatus(), + input: mkValidEvictionRequestStatus(func(obj *lifecycle.EvictionRequestStatus) { + obj.Conditions = append(obj.Conditions, metav1.Condition{ + Type: "-bad-name", + Status: metav1.ConditionTrue, + Reason: "-Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + }) + }), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions").Index(0).Child("type"), "", "name part must consist of alphanumeric characters, '-', '_' or '.', and "), + field.Invalid(field.NewPath("status", "conditions").Index(0).Child("reason"), "", "a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and "), + }, + }, + } + + for name, tc := range failureCases { + t.Run(name, func(t *testing.T) { + oldEvictionRequest := mkValidEvictionRequest() + oldEvictionRequest.ResourceVersion = "0" + oldEvictionRequest.Status = *tc.oldInput + evictionRequest := mkValidEvictionRequest() + evictionRequest.ResourceVersion = "1" + evictionRequest.Status = *tc.input + errs := ValidateEvictionRequestStatusUpdate(evictionRequest, oldEvictionRequest) + if len(errs) == 0 { + t.Errorf("Expected failure") + return + } + if len(errs) != len(tc.errors) { + errsFormated := "\n" + for _, err := range errs { + errsFormated += err.Error() + "\n" + } + t.Errorf("Expected %d errors, got %d: %v", len(tc.errors), len(errs), errsFormated) + return + } + matcher := field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring() + matcher.Test(t, tc.errors, errs) + + for i, err := range errs { + expectedErr := tc.errors[i] + if err.CoveredByDeclarative != expectedErr.CoveredByDeclarative { + t.Errorf("Error %d: expected CoveredByDeclarative=%v, got %v for error: %v", + i, expectedErr.CoveredByDeclarative, err.CoveredByDeclarative, err) + } + } + }) + } +} + +func TestValidateEviction(t *testing.T) { + successCases := map[string]struct { + input *lifecycle.Eviction + }{ + "valid: pod target": { + input: mkValidEviction(), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + errs := ValidateEviction(tc.input) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + } + }) + } + + failureCases := map[string]struct { + input *lifecycle.Eviction + + errors []*field.Error + }{ + "missing namespace": { + input: mkValidEviction(setNamespace("")), + errors: []*field.Error{ + field.Required(field.NewPath("metadata", "namespace"), ""), + }, + }, + } + + for name, tc := range failureCases { + t.Run(name, func(t *testing.T) { + errs := ValidateEviction(tc.input) + if len(errs) == 0 { + t.Errorf("Expected failure") + return + } + if len(errs) != len(tc.errors) { + t.Errorf("Expected %d errors, got %d: %v", len(tc.errors), len(errs), errs) + return + } + matcher := field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring() + matcher.Test(t, tc.errors, errs) + + for i, err := range errs { + expectedErr := tc.errors[i] + if err.CoveredByDeclarative != expectedErr.CoveredByDeclarative { + t.Errorf("Error %d: expected CoveredByDeclarative=%v, got %v for error: %v", + i, expectedErr.CoveredByDeclarative, err.CoveredByDeclarative, err) + } + } + }) + } +} + +func TestValidateEvictionUpdate(t *testing.T) { + successCases := map[string]struct { + input *lifecycle.Eviction + oldInput *lifecycle.Eviction + }{ + "set requester annotation": { + oldInput: mkValidEviction(), + input: mkValidEviction(addAnnotation("acme.io/foo", "requester")), + }, + "update requester annotations": { + oldInput: mkValidEviction(addAnnotation("acme.io/foo", "requester")), + input: mkValidEviction(addAnnotation("acme.io/foo", "requester-responder")), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + tc.oldInput.ResourceVersion = "0" + tc.input.ResourceVersion = "1" + errs := ValidateEvictionUpdate(tc.input, tc.oldInput) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + return + } + }) + } +} + +func TestValidateEvictionStatusUpdate(t *testing.T) { + clock := testing2.NewFakePassiveClock(time.Now()) + clockBefore := func(duration time.Duration) utilsclock.PassiveClock { + return testing2.NewFakePassiveClock(clock.Now().Add(-duration)) + } + clockAfter := func(duration time.Duration) utilsclock.PassiveClock { + return testing2.NewFakePassiveClock(clock.Now().Add(duration)) + } + clock2 := clockAfter(5 * time.Second) + + successCases := map[string]struct { + clock utilsclock.PassiveClock + input *lifecycle.EvictionStatus + oldInput *lifecycle.EvictionStatus + explicitEmptyUpdate bool + }{ + // conditions + "Evicted condition can be set": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "Evicted condition can be changed to true": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, false), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "Failed condition can be set": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "Failed condition can be changed to true": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, false), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "Non terminal condition can be added when immutable condition exists": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, "NewCondition", true), addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "Non terminal condition can be changed when immutable condition exists": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, "NewCondition", true), addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, "NewCondition", false), addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + }, + "A new condition can be added when observedGeneration is nil": { + oldInput: mkValidEvictionStatus(0, setObservedGeneration(nil), addCondition(clock, "NewCondition", true), addCondition(clock, lifecycle.EvictionConditionFailed, true)), + input: mkValidEvictionStatus(0, setObservedGeneration(nil), addCondition(clock, "NewCondition", false), addCondition(clock, lifecycle.EvictionConditionFailed, true)), + }, + // observedGeneration + "set initial generation": { + oldInput: &lifecycle.EvictionStatus{}, + input: mkValidEvictionStatus(0), + }, + "update generation": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, setObservedGeneration(new(int64(5)))), + }, + // requesters + "initialize requester with eviction intent": { + oldInput: mkValidEvictionStatus(0, clearRequesters()), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + }, + "initialize requester with withdrawn intent": { + oldInput: mkValidEvictionStatus(0, clearRequesters()), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentWithdrawn, "foo.example.com/baz")), + }, + "change requester intent from eviction to withdrawn": { + oldInput: mkValidEvictionStatus(0, clearRequesters(), addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + input: mkValidEvictionStatus(0, clearRequesters(), addRequesters(lifecycle.RequesterIntentWithdrawn, "foo.example.com/baz")), + }, + "change requester intent from withdrawn to eviction": { + oldInput: mkValidEvictionStatus(0, clearRequesters(), addRequesters(lifecycle.RequesterIntentWithdrawn, "foo.example.com/baz")), + input: mkValidEvictionStatus(0, clearRequesters(), addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + }, + "add a second requester with eviction intent": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + }, + "change a last requester eviction intent to withdrawn": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, clearRequesters(), addRequesters(lifecycle.RequesterIntentWithdrawn, "foo.example.com/bar")), + }, + // first sync + "responders initialization": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(3), + }, + "11 responders": { // 1 more than Pod's spec.evictionResponders + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(11), + }, + "first targetResponder can be inactive during the first sync if the request has failed": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionFailed, true), + ), + }, + "first targetResponder can be inactive during the first sync if the request has completed": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + ), + }, + // Full Eviction progression/lifecycle + "mark active and started": { + oldInput: mkValidEvictionStatus(2), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + }, + "responder reports progress": { + oldInput: mkValidEvictionStatus(2), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + }, + "responder reports next progress": { + clock: clockAfter(time.Minute + 5*time.Second), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(time.Minute+5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + }, + "cancel responder": { + clock: clockAfter(time.Minute + 5*time.Second), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + input: mkValidEvictionStatus(2, + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCanceled, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + }, + + "interrupt responder": { + clock: clockAfter(time.Minute + 5*time.Second), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clock, 0, 1)), + }, + "responder reports complete": { + clock: clockAfter(5 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockAfter(time.Minute+5*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(10*time.Minute), 0, 1), + setRespondersMessage(0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1)), + }, + "move active complete to processed and set new active": { + clock: clockAfter(5*time.Minute + 27*time.Second), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2)), + }, + "next responder reports next progress": { + clock: clockAfter(25 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2), + setRespondersHeartBeatTime(clockAfter(25*time.Minute), 1, 2)), + }, + "next responder reports complete": { + clock: clockAfter(28 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2), + setRespondersHeartBeatTime(clockAfter(25*time.Minute), 1, 2)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersFullStatus(clockAfter(5*time.Minute+30*time.Second), clockAfter(27*time.Minute+32*time.Second), 1, 2)), + }, + "finalize eviction request active": { + clock: clockAfter(35 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersFullStatus(clockAfter(5*time.Minute+30*time.Second), clockAfter(27*time.Minute+32*time.Second), 1, 2)), + input: mkValidEvictionStatus(2, + setCompletedCount(2), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersFullStatus(clockAfter(5*time.Minute+30*time.Second), clockAfter(27*time.Minute+32*time.Second), 1, 2)), + }, + "cancel eviction request for the next responder": { + clock: clockAfter(35 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2), + setRespondersHeartBeatTime(clockAfter(25*time.Minute), 1, 2)), + input: mkValidEvictionStatus(2, + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCanceled, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2), + setRespondersHeartBeatTime(clockAfter(25*time.Minute), 1, 2)), + }, + "interrupt eviction request for the next responder": { + clock: clockAfter(35 * time.Minute), + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2)), + input: mkValidEvictionStatus(2, + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateInterrupted, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersFullStatus(clock, clockAfter(5*time.Minute+27*time.Second), 0, 1), + setRespondersStartTime(clockAfter(5*time.Minute+30*time.Second), 1, 2)), + }, + "finalize eviction request active - large number of responders": { + clock: clockAfter(5 * time.Minute), + oldInput: mkValidEvictionStatus(10, + setStateFor(lifecycle.ResponderStateActive, 9), + setCompletedCount(9), + setRespondersFullStatus(clockAfter(time.Minute), clockAfter(2*time.Minute), 0, 4), + setRespondersFullStatus(clockAfter(3*time.Minute), clockAfter(4*time.Minute), 4, 10)), + input: mkValidEvictionStatus(10, + setCompletedCount(10), + setRespondersFullStatus(clockAfter(time.Minute), clockAfter(2*time.Minute), 0, 4), + setRespondersFullStatus(clockAfter(3*time.Minute), clockAfter(4*time.Minute), 4, 10)), + }, + // allowed update after the heartbeat deadline + "processedResponders can be updated when the deadline (start time fallback) is reached": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(19*time.Minute+32*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(19*time.Minute+32*time.Second), 0, 1)), + }, + "processedResponders can be updated when the deadline is reached": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Hour), 0, 1), + setRespondersHeartBeatTime(clockBefore(20*time.Minute), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(time.Hour), 0, 1), + setRespondersHeartBeatTime(clockBefore(20*time.Minute), 0, 1)), + }, + // startTime + "startTime can be set to the present time with an allowed skew - slower clock": { + oldInput: mkValidEvictionStatus(2), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(29*time.Second), 0, 1)), + }, + "responder startTime can be set to the present time with an allowed skew - faster clock": { + oldInput: mkValidEvictionStatus(2), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockAfter(27*time.Second), 0, 1)), + }, + // heartbeatTime + "heartbeatTime can be set to the present time with an allowed skew - slower clock": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(27*time.Second), 0, 1)), + }, + "heartbeatTime can be set to the present time with an allowed skew - faster clock": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockAfter(27*time.Second), 0, 1)), + }, + "heartbeatTime can be updated after one minute": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clock, 0, 1)), + }, + // expectedCompletionTime + "expectedCompletionTime can be set to the present time with an allowed skew - slower clock": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1), + setRespondersExpectedCompletionTime(clockBefore(27*time.Second), 0, 1)), + }, + "expectedCompletionTime can be set to the future": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(time.Hour*24*365*10-time.Minute), 0, 1)), + }, + // completionTime + "completionTime can be set to the present time with an allowed skew - faster clock": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockAfter(27*time.Second), 0, 1)), + }, + // processedResponders + conditions + "processedResponders must allow active responder transition even with an exceeded deadline (start time fallback) - Failed": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1), + addCondition(clock, lifecycle.EvictionConditionFailed, true)), + }, + "processedResponders must allow active responder transition even with an exceeded deadline (start time fallback) - Evicted": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true)), + }, + "processedResponders must allow active responder transition even with an exceeded deadline - Failed": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1), + addCondition(clock, lifecycle.EvictionConditionFailed, true)), + }, + "processedResponders must allow active responder transition even with an exceeded deadline - Evicted": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true)), + }, + "second targetResponder completes without a final condition": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + }, + // empty syncs + "empty sync during heartbeat wait": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute), 0, 1)), + explicitEmptyUpdate: true, + }, + "all targetResponder completed without a final condition: empty sync": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + explicitEmptyUpdate: true, + }, + "all targetResponder completed with a final condition: empty sync": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + explicitEmptyUpdate: true, + }, + // k8s responders with assigned priorities + "imperative-eviction.k8s.io/evictor k8s responder cannot have other priority than the assigned one": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addTargetAndStatusResponders(string(lifecycle.EvictionResponderImperativeEviction)), setPriorityFor(100, 0), setStateFor(lifecycle.ResponderStateActive, 0), setRespondersStartTime(clock, 0, 1)), + }, + } + for name, tc := range successCases { + t.Run(name, func(t *testing.T) { + if !tc.explicitEmptyUpdate && validate.SemanticDeepEqual(tc.oldInput, tc.input) { + t.Errorf("Expected oldInput and input to differ") + } + // Ensure that the validation doesn't depend on the order of list elements. + shuffleTargetResponders()(tc.oldInput) + shuffleResponders()(tc.oldInput) + shuffleRequesters()(tc.oldInput) + shuffleTargetResponders()(tc.input) + shuffleResponders()(tc.input) + shuffleRequesters()(tc.input) + oldEviction := mkValidEviction() + oldEviction.ResourceVersion = "0" + oldEviction.Status = *tc.oldInput + eviction := mkValidEviction() + eviction.ResourceVersion = "1" + eviction.Status = *tc.input + opts := EvictionStatusValidationOptions{clock} + if tc.clock != nil { + opts.Clock = tc.clock + } + errs := ValidateEvictionStatusUpdate(eviction, oldEviction, opts) + if len(errs) != 0 { + t.Errorf("Expected success for %q: %v", name, errs) + } + }) + } + + failureCases := map[string]struct { + input *lifecycle.EvictionStatus + oldInput *lifecycle.EvictionStatus + + errors []*field.Error + }{ + // conditions + "add invalid condition": { + oldInput: mkValidEvictionStatus(1), + input: mkValidEvictionStatus(1, func(obj *lifecycle.EvictionStatus) { + obj.Conditions = append(obj.Conditions, metav1.Condition{ + Type: "-bad-name", + Status: metav1.ConditionTrue, + Reason: "-Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + }) + }), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions").Index(0).Child("type"), "", "name part must consist of alphanumeric characters, '-', '_' or '.', and "), + field.Invalid(field.NewPath("status", "conditions").Index(0).Child("reason"), "", "a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and "), + }, + }, + "Evicted condition cannot be removed": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions"), "", "Evicted condition status cannot be reverted"), + }, + }, + "Evicted condition cannot be changed": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionTargetEvicted, false), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions"), "", "Evicted condition status cannot be reverted"), + }, + }, + "Failed condition cannot be removed": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions"), "", "Failed condition status cannot be reverted"), + }, + }, + "Failed condition cannot be changed": { + oldInput: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + input: mkValidEvictionStatus(1, addCondition(clock, lifecycle.EvictionConditionFailed, false), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "conditions"), "", "Failed condition status cannot be reverted"), + }, + }, + // requesters + // targetResponders and responders + "cannot change keys in targetResponders - remove": { + oldInput: mkValidEvictionStatus(5), + input: mkValidEvictionStatus(4), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders"), "", "must preserve the same length and the same keys"), + }, + }, + "cannot change keys in targetResponders - add": { + oldInput: mkValidEvictionStatus(4), + input: mkValidEvictionStatus(5), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders"), "", "must preserve the same length and the same keys"), + }, + }, + // targetResponders + "cannot change keys in targetResponders - change": { + oldInput: mkValidEvictionStatus(4), + input: mkValidEvictionStatus(3, addTargetResponders("foo/bar"), addStatusResponders(responderName(3))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders"), "", "must preserve the same keys"), + field.Invalid(field.NewPath("status", "responders"), "", "must contain the same keys as status.targetResponders"), + }, + }, + "targetResponders must have matching status responder": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addTargetResponders("foo/bar")), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0), "", "has to be tracked in status.responders first"), + field.Invalid(field.NewPath("status", "responders"), "", "must be the same length as status.targetResponders and contain the same keys"), + }, + }, + // priority + "non k8s responder cannot have priority below 1000: 0": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setPriorityFor(0, 0)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "priorities 0-999 are reserved for responders with *.k8s.io suffix"), + }, + }, + "non k8s responder cannot have priority below 1000: 999": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setPriorityFor(0, 0)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "priorities 0-999 are reserved for responders with *.k8s.io suffix"), + }, + }, + "imperative-eviction.k8s.io/evictor k8s responder cannot have other priority than the assigned one": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addTargetAndStatusResponders(string(lifecycle.EvictionResponderImperativeEviction)), setPriorityFor(101, 0), setStateFor(lifecycle.ResponderStateActive, 0), setRespondersStartTime(clock, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "core k8s responder is expected to have priority: 100"), + }, + }, + // targetResponder state transitions + "first targetResponder should become active first ": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateInactive, 0), setStateFor(lifecycle.ResponderStateActive, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Inactive, because the previously active has not finished processing"), + field.Required(field.NewPath("status", "responders").Index(1).Child("startTime"), ""), + }, + }, + "first targetResponder should become active first during the first sync and not completed": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "first targetResponder should become active first during the first sync and not interrupted": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateInterrupted, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "first targetResponder should become active first during the first sync and not canceled": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCanceled, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "first targetResponder should become active first during the first sync and not inactive": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateInactive, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "first targetResponder should not become active first during the first sync with final condition": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Inactive, because the eviction request has finished processing"), + }, + }, + "first targetResponder should not become completed first during the first sync with final condition": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCompleted, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Inactive, because the eviction request has finished processing"), + }, + }, + "first targetResponder should not become interrupted first during the first sync with final condition": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateInterrupted, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Inactive, because the eviction request has finished processing"), + }, + }, + "first targetResponder should not become canceled first during the first sync with final condition": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + addCondition(clock, lifecycle.EvictionConditionFailed, true), + setStateFor(lifecycle.ResponderStateCanceled, 0), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Inactive, because the eviction request has finished processing"), + }, + }, + "second targetResponder should not become active while the first reponder is active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Inactive, because the previously active has not finished processing"), + field.Required(field.NewPath("status", "responders").Index(1).Child("startTime"), ""), + }, + }, + "second targetResponder should not become completed while the first reponder is active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Inactive, because the previously active has not finished processing"), + }, + }, + "second targetResponder should not become interrupted while the first reponder is active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateInterrupted, 1), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Inactive, because the previously active has not finished processing"), + }, + }, + "second targetResponder should not become canceled while the first reponder is active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateCanceled, 1), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Inactive, because the previously active has not finished processing"), + }, + }, + "first targetResponder should not be inactive once it completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateInactive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Canceled, Completed, Interrupted, because this responder must reach a final state"), + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "second targetResponder should not be inactive once the first reponder completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "second targetResponder should not be completed once the first reponder completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "second targetResponder should not be interrupted once the first reponder completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateInterrupted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "second targetResponder should not be canceled once the first reponder completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCanceled, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "first targetResponder is immutable once it completes": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Completed, because final state is immutable"), + }, + }, + "second targetResponder completes, should not allow a change of the first one": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateActive, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Completed, because final state is immutable"), + }, + }, + "second targetResponder completes, should not stay active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Canceled, Completed, Interrupted, because the eviction request has finished processing"), + }, + }, + "second targetResponder completes, should not become inactive": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateInactive, 1), + addCondition(clock, lifecycle.EvictionConditionTargetEvicted, true), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(1).Child("state"), "", "must be one of: Canceled, Completed, Interrupted, because this responder must reach a final state"), + }, + }, + "second targetResponder completes, third should become active": { + oldInput: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1), responderName(2)), + setStateFor(lifecycle.ResponderStateCompleted, 0), setStateFor(lifecycle.ResponderStateCompleted, 1), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clock2, 0, 1), + setRespondersStartTime(clock2, 1, 2), + setRespondersCompletionTime(clock2, 1, 2), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(2).Child("state"), "", "must be one of: Active, because this responder is next in line"), + }, + }, + "Active responder must exceed the deadline (start time fallback) before it is marked Completed": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + errors: []*field.Error{ + field.Forbidden(field.NewPath("status", "targetResponders").Index(0).Child("state"), "must stay Active because the responder is in progress"), + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "cannot become Completed because the responder didn't report status.responders.completionTime"), + }, + }, + "Active responder must exceed the deadline before it is marked Completed": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + errors: []*field.Error{ + field.Forbidden(field.NewPath("status", "targetResponders").Index(0).Child("state"), "must stay Active because the responder is in progress"), + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "cannot become Completed because the responder didn't report status.responders.completionTime"), + }, + }, + "Active responder must exceed the deadline before it is marked Interrupted": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateInterrupted, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + errors: []*field.Error{ + field.Forbidden(field.NewPath("status", "targetResponders").Index(0).Child("state"), "must stay Active because the responder is in progress"), + }, + }, + "Active responder must exceed the deadline before it is marked Canceled": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCanceled, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute+29*time.Second), 0, 1)), + errors: []*field.Error{ + field.Forbidden(field.NewPath("status", "targetResponders").Index(0).Child("state"), "must stay Active because the responder is in progress"), + }, + }, + "Active responder exceeds the deadline, but cannot be marked Completed without CompletionTime": { + oldInput: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(20*time.Minute), 0, 1)), + input: mkValidEvictionStatus(1, + setStateFor(lifecycle.ResponderStateCompleted, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(20*time.Minute), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "cannot become Completed because the responder didn't report status.responders.completionTime"), + }, + }, + // responders + "responders must be initialized at the same time as targetResponders": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatusWithStatuses(0, 3), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders"), "", "must be the same length as status.targetResponders and contain the same keys"), + }, + }, + "cannot change keys in responders - change": { + oldInput: mkValidEvictionStatus(4), + input: mkValidEvictionStatus(3, addTargetResponders(responderName(3)), addStatusResponders("foo/bar")), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(3), "", "has to be tracked in status.responders first"), + field.Invalid(field.NewPath("status", "responders"), "", "must contain the same keys as status.targetResponders"), + }, + }, + "status responders cannot remove an item": { + oldInput: mkValidEvictionStatus(5), + input: mkValidEvictionStatusWithStatuses(5, 4), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(4), "", "has to be tracked in status.responders first"), + field.Invalid(field.NewPath("status", "responders"), "", "must be the same length as status.targetResponders and contain the same keys"), + }, + }, + "status responders cannot be mutated unless present in active responders": { + oldInput: mkValidEvictionStatus(5), + input: mkValidEvictionStatus(5, + setRespondersHeartBeatTime(clock, 1, 2)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(1), "", validation.FieldImmutableErrorMsg).WithOrigin("immutable"), + }, + }, + // status responder name + "invalid status responders names - short circuited by targetResponders key order": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatusWithStatuses(1, 0, + addStatusResponders("foo")), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0), "", "has to be tracked in status.responders first"), + field.Invalid(field.NewPath("status", "responders"), "", "must contain the same keys as status.targetResponders"), + }, + }, + // startTime + "startTime is required for an active responder": { + oldInput: mkValidEvictionStatus(2, + setRespondersFullStatus(clock, clock2, 0, 1)), + input: mkValidEvictionStatus(2, + setRespondersFullStatus(clock, clock2, 0, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setStateFor(lifecycle.ResponderStateActive, 1)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "responders").Index(1).Child("startTime"), "is required for an active responder"), + }, + }, + "startTime must be set to the present time with skew; too old": { + oldInput: mkValidEvictionStatus(2, + setRespondersFullStatus(clock, clock2, 0, 1)), + input: mkValidEvictionStatus(2, setRespondersFullStatus(clock, clock2, 0, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clockBefore(31*time.Second), 1, 2)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(1).Child("startTime"), "", "must be set to the present time"), + }, + }, + "startTime must be set to the present time with skew; too new": { + oldInput: mkValidEvictionStatus(2, + setRespondersFullStatus(clock, clock2, 0, 1)), + input: mkValidEvictionStatus(2, setRespondersFullStatus(clock, clock2, 0, 1), + setStateFor(lifecycle.ResponderStateCompleted, 0), + setStateFor(lifecycle.ResponderStateActive, 1), + setRespondersStartTime(clockAfter(31*time.Second), 1, 2)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(1).Child("startTime"), "", "must be set to the present time"), + }, + }, + // heartbeatTime + "cannot remove heartbeatTime when previously set": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "responders").Index(0).Child("heartbeatTime"), "is required once set"), + }, + }, + "heartbeatTime cannot be set before startTime is set": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersHeartBeatTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "responders").Index(0).Child("startTime"), "is required for an active responder"), + field.Invalid(field.NewPath("status", "responders").Index(0).Child("heartbeatTime"), "", "cannot be set before status.responders[0].startTime"), + }, + }, + "heartbeatTime cannot be decreased": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Second), 0, 1), + setRespondersHeartBeatTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Second), 0, 1), + setRespondersHeartBeatTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("heartbeatTime"), "", "cannot be decreased"), + }, + }, + "heartbeatTime cannot be set before startTime even with a skew": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("heartbeatTime"), "", "must occur after status.responders[0].startTime"), + }, + }, + "heartbeatTime must be set to the present time with skew": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockBefore(19*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(20*time.Minute), 0, 1), + setRespondersHeartBeatTime(clockAfter(31*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("heartbeatTime"), "", "must be set to the present time"), + }, + }, + // expectedCompletionTime + "expectedCompletionTime cannot be set before startTime is set": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersExpectedCompletionTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "responders").Index(0).Child("startTime"), "is required for an active responder"), + field.Invalid(field.NewPath("status", "responders").Index(0).Child("expectedCompletionTime"), "", "cannot be set before status.responders[0].startTime"), + }, + }, + "expectedCompletionTime cannot be set before startTime even with a skew": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersExpectedCompletionTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("expectedCompletionTime"), "", "must occur after status.responders[0].startTime"), + }, + }, + "expectedCompletionTime cannot be set to the past with skew": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(32*time.Second), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(32*time.Second), 0, 1), + setRespondersExpectedCompletionTime(clockBefore(31*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("expectedCompletionTime"), "", "cannot be set to the past time"), + }, + }, + "expectedCompletionTime must complete within 10 years": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(time.Minute), 0, 1), + setRespondersExpectedCompletionTime(clockAfter(time.Hour*24*365*10+time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("expectedCompletionTime"), "", "must complete within 10 years"), + }, + }, + // completionTime + "completionTime cannot be set before startTime is set": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetAndStatusResponders(responderName(0), responderName(1)), + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersCompletionTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "responders").Index(0).Child("startTime"), "is required for an active responder"), + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), "", "cannot be set before status.responders[0].startTime"), + }, + }, + "completionTime cannot be set before startTime even with a skew": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockBefore(2*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), "", "must occur after status.responders[0].startTime"), + }, + }, + "completionTime must be set to the present time with skew; too new": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockAfter(31*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), "", "must be set to the present time"), + }, + }, + "completionTime must be set to the present time with skew; too old": { + oldInput: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(1*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setStateFor(lifecycle.ResponderStateActive, 0), + setRespondersStartTime(clockBefore(1*time.Minute), 0, 1), + setRespondersCompletionTime(clockBefore(31*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), "", "must be set to the present time"), + }, + }, + // message + } + + for name, tc := range failureCases { + t.Run(name, func(t *testing.T) { + oldEviction := mkValidEviction() + oldEviction.ResourceVersion = "0" + oldEviction.Status = *tc.oldInput + eviction := mkValidEviction() + eviction.ResourceVersion = "1" + eviction.Status = *tc.input + errs := ValidateEvictionStatusUpdate(eviction, oldEviction, EvictionStatusValidationOptions{clock}) + if len(errs) == 0 { + t.Errorf("Expected failure") + return + } + if len(errs) != len(tc.errors) { + errsFormated := "\n" + for _, err := range errs { + errsFormated += err.Error() + "\n" + } + t.Errorf("Expected %d errors, got %d: %v", len(tc.errors), len(errs), errsFormated) + return + } + matcher := field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring() + matcher.Test(t, tc.errors, errs) + + for i, err := range errs { + expectedErr := tc.errors[i] + if err.CoveredByDeclarative != expectedErr.CoveredByDeclarative { + t.Errorf("Error %d: expected CoveredByDeclarative=%v, got %v for error: %v", + i, expectedErr.CoveredByDeclarative, err.CoveredByDeclarative, err) + } + } + }) + } +} + +func mkValidEvictionRequest(tweaks ...func(obj *lifecycle.EvictionRequest)) *lifecycle.EvictionRequest { + obj := lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo"}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + Requester: "foo.example.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} + +func setERNamespace(namespace string) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Namespace = namespace + } +} + +func setRequesterIntent(intent lifecycle.EvictionRequestIntent) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Spec.Intent = intent + } +} + +func mkValidEvictionRequestStatus(tweaks ...func(obj *lifecycle.EvictionRequestStatus)) *lifecycle.EvictionRequestStatus { + obj := lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} +func addERCondition(clock utilsclock.PassiveClock, name lifecycle.EvictionConditionType, status bool) func(obj *lifecycle.EvictionRequestStatus) { + return func(obj *lifecycle.EvictionRequestStatus) { + newCond := metav1.Condition{ + Type: string(name), + Status: metav1.ConditionFalse, + Reason: string(name) + "Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + } + if status { + newCond.Status = metav1.ConditionTrue + } + obj.Conditions = append(obj.Conditions, newCond) + } +} + +func setERObservedGeneration(generation *int64) func(obj *lifecycle.EvictionRequestStatus) { + return func(obj *lifecycle.EvictionRequestStatus) { + obj.ObservedGeneration = generation + } +} + +func mkValidEviction(tweaks ...func(obj *lifecycle.Eviction)) *lifecycle.Eviction { + obj := lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: validUID, Namespace: "foo"}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} + +func setNamespace(namespace string) func(obj *lifecycle.Eviction) { + return func(obj *lifecycle.Eviction) { + obj.Namespace = namespace + } +} +func addAnnotation(key, value string) func(obj *lifecycle.Eviction) { + return func(obj *lifecycle.Eviction) { + if obj.Labels == nil { + obj.Labels = make(map[string]string) + } + obj.Labels[key] = value + } +} + +func responderName(i int) string { + return fmt.Sprintf("responder.example.com/bar%d", i) +} +func mkValidEvictionStatus(responders int, tweaks ...func(obj *lifecycle.EvictionStatus)) *lifecycle.EvictionStatus { + return mkValidEvictionStatusWithStatuses(responders, responders, tweaks...) +} +func mkValidEvictionStatusWithStatuses(responders, statuses int, tweaks ...func(obj *lifecycle.EvictionStatus)) *lifecycle.EvictionStatus { + obj := lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + } + for i := range responders { + obj.TargetResponders = append(obj.TargetResponders, lifecycle.TargetResponder{ + Name: responderName(i), + Priority: new(5000 - int32(i)), + State: lifecycle.ResponderStateInactive, + }) + if i == 0 { + obj.TargetResponders[i].State = lifecycle.ResponderStateActive + } + } + for i := range statuses { + obj.Responders = append(obj.Responders, lifecycle.ResponderStatus{ + Name: responderName(i), + }) + if i == 0 { + obj.Responders[i].StartTime = new(metav1.Now()) + } + } + addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/bar") + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} +func addCondition(clock utilsclock.PassiveClock, name lifecycle.EvictionConditionType, status bool) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + newCond := metav1.Condition{ + Type: string(name), + Status: metav1.ConditionFalse, + Reason: string(name) + "Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + } + if status { + newCond.Status = metav1.ConditionTrue + } + obj.Conditions = append(obj.Conditions, newCond) + } +} + +func setObservedGeneration(generation *int64) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.ObservedGeneration = generation + } +} +func addTargetResponders(responders ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + lastPriority := len(obj.Responders) + for i, name := range responders { + obj.TargetResponders = append(obj.TargetResponders, lifecycle.TargetResponder{ + Name: name, + Priority: new(5000 - int32(lastPriority+i)), + State: lifecycle.ResponderStateInactive, + }) + } + } +} +func clearRequesters() func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.Requesters = nil + } +} +func addRequesters(intent lifecycle.RequesterIntent, names ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for _, name := range names { + obj.Requesters = append(obj.Requesters, lifecycle.Requester{Name: name, Intent: intent}) + } + } +} +func addTargetAndStatusResponders(responders ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + addTargetResponders(responders...)(obj) + addStatusResponders(responders...)(obj) + } +} +func setStateFor(state lifecycle.ResponderStateType, idx int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.TargetResponders[idx].State = state + } +} +func setPriorityFor(priority int32, idx int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.TargetResponders[idx].Priority = new(priority) + } +} +func setCompletedCount(count int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := range count { + if i < len(obj.TargetResponders) { + obj.TargetResponders[i].State = lifecycle.ResponderStateCompleted + } + } + } +} +func addStatusResponders(responders ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for _, responder := range responders { + obj.Responders = append(obj.Responders, lifecycle.ResponderStatus{Name: responder}) + } + } +} +func setRespondersStartTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].StartTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersHeartBeatTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].HeartbeatTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersExpectedCompletionTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].ExpectedCompletionTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersCompletionTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].CompletionTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersMessage(from, to int, suffixes ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + msg := fmt.Sprintf("message %d", i) + for _, suffix := range suffixes { + msg += suffix + } + obj.Responders[i].Message = new(msg) + } + } +} +func setRespondersFullStatus(startTimeClock, clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + setRespondersStartTime(startTimeClock, from, to)(obj) + setRespondersHeartBeatTime(clock, from, to)(obj) + setRespondersExpectedCompletionTime(clock, from, to)(obj) + setRespondersCompletionTime(clock, from, to)(obj) + setRespondersMessage(from, to, clock.Now().String())(obj) + } +} + +func shuffleRequesters() func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + rand.Shuffle(len(obj.Requesters), func(i, j int) { + obj.Requesters[i], obj.Requesters[j] = obj.Requesters[j], obj.Requesters[i] + }) + } +} +func shuffleTargetResponders() func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + rand.Shuffle(len(obj.TargetResponders), func(i, j int) { + obj.TargetResponders[i], obj.TargetResponders[j] = obj.TargetResponders[j], obj.TargetResponders[i] + }) + } +} +func shuffleResponders() func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + rand.Shuffle(len(obj.Responders), func(i, j int) { + obj.Responders[i], obj.Responders[j] = obj.Responders[j], obj.Responders[i] + }) + } +} + +func TestSortTargetResponders(t *testing.T) { + testCases := map[string]struct { + expectedResponders []lifecycle.TargetResponder + }{ + "sorts by priority": { + expectedResponders: []lifecycle.TargetResponder{ + {Name: responderName(1), Priority: new(int32(100000))}, + {Name: responderName(9), Priority: new(int32(99999))}, + {Name: responderName(10), Priority: new(int32(10001))}, + {Name: responderName(2), Priority: new(int32(10000))}, + {Name: responderName(13), Priority: new(int32(5))}, + {Name: responderName(4), Priority: new(int32(4))}, + {Name: responderName(12), Priority: new(int32(3))}, + {Name: responderName(6), Priority: new(int32(3))}, + {Name: responderName(11), Priority: new(int32(2))}, + {Name: responderName(8), Priority: new(int32(1))}, + {Name: responderName(7), Priority: new(int32(0))}, + {Name: responderName(3), Priority: new(int32(-1))}, // doesn't pass API validation + {Name: responderName(14), Priority: new(int32(-1000))}, // doesn't pass API validation + {Name: responderName(5), Priority: nil}, // doesn't pass API validation + }, + }, + "sorts by domain name prefix and key when priority is the same": { + expectedResponders: []lifecycle.TargetResponder{ + {Name: "responder-x.example.com/bar", Priority: new(int32(16))}, + {Name: "responder-c.fooexample.com/bar", Priority: new(int32(16))}, + {Name: "example.com/bar", Priority: new(int32(15))}, + {Name: "example.com/baz", Priority: new(int32(15))}, + {Name: "responder-y.example.com/bar", Priority: new(int32(15))}, + {Name: "responder-y.example.com/baz", Priority: new(int32(15))}, + {Name: "responder-z.example.com/bar", Priority: new(int32(15))}, + {Name: "responder-z.example.com/baz", Priority: new(int32(15))}, + {Name: "fooexample.com/bar", Priority: new(int32(15))}, + {Name: "responder-a.fooexample.com/bar", Priority: new(int32(15))}, + {Name: "responder-b.fooexample.com/bar", Priority: new(int32(15))}, + {Name: "responder-b.fooexample.com/baz", Priority: new(int32(15))}, + {Name: "responder-b.barexample.io/bar", Priority: new(int32(15))}, + {Name: "responder-b.fooexample.io/bar", Priority: new(int32(15))}, + {Name: "responder-b.fooexample.io/baz", Priority: new(int32(15))}, + {Name: "responder-k.kubernetes.io/y", Priority: new(int32(14))}, + {Name: "responder-k.kubernetes.io/y", Priority: new(int32(14))}, + {Name: "responder-k.kubernetes.io/z", Priority: new(int32(14))}, + {Name: "responder-k.kubernetes.io/x", Priority: new(int32(13))}, + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + input := append([]lifecycle.TargetResponder{}, tc.expectedResponders...) + rand.Shuffle(len(input), func(i, j int) { + input[i], input[j] = input[j], input[i] + }) + SortTargetResponders(input) + if diff := cmp.Diff(input, tc.expectedResponders); len(diff) > 0 { + t.Errorf("unexpected TargetResponders sort: %v", diff) + } + }) + } +} diff --git a/pkg/controlplane/import_known_versions.go b/pkg/controlplane/import_known_versions.go index 77cf606cd5c..44a837e648c 100644 --- a/pkg/controlplane/import_known_versions.go +++ b/pkg/controlplane/import_known_versions.go @@ -34,6 +34,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/extensions/install" _ "k8s.io/kubernetes/pkg/apis/flowcontrol/install" _ "k8s.io/kubernetes/pkg/apis/imagepolicy/install" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" _ "k8s.io/kubernetes/pkg/apis/networking/install" _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" diff --git a/pkg/controlplane/instance.go b/pkg/controlplane/instance.go index 74a53ccc27d..c80c87a41a3 100644 --- a/pkg/controlplane/instance.go +++ b/pkg/controlplane/instance.go @@ -44,6 +44,7 @@ import ( apiv1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" eventsv1 "k8s.io/api/events/v1" + lifecyclev1alpha1 "k8s.io/api/lifecycle/v1alpha1" networkingapiv1 "k8s.io/api/networking/v1" networkingapiv1beta1 "k8s.io/api/networking/v1beta1" nodev1 "k8s.io/api/node/v1" @@ -99,6 +100,7 @@ import ( discoveryrest "k8s.io/kubernetes/pkg/registry/discovery/rest" eventsrest "k8s.io/kubernetes/pkg/registry/events/rest" flowcontrolrest "k8s.io/kubernetes/pkg/registry/flowcontrol/rest" + lifecyclerest "k8s.io/kubernetes/pkg/registry/lifecycle/rest" networkingrest "k8s.io/kubernetes/pkg/registry/networking/rest" noderest "k8s.io/kubernetes/pkg/registry/node/rest" policyrest "k8s.io/kubernetes/pkg/registry/policy/rest" @@ -427,6 +429,7 @@ func (c CompletedConfig) StorageProviders(client *kubernetes.Clientset) ([]contr certificatesrest.RESTStorageProvider{Authorizer: c.ControlPlane.Generic.Authorization.Authorizer}, coordinationrest.RESTStorageProvider{}, discoveryrest.StorageProvider{}, + lifecyclerest.RESTStorageProvider{}, networkingrest.RESTStorageProvider{}, noderest.RESTStorageProvider{}, policyrest.RESTStorageProvider{}, @@ -513,6 +516,7 @@ var ( } // alphaAPIGroupVersionsDisabledByDefault holds the alpha APIs we have for additional API groups only provided in kube-apiserver. They are always disabled by default. alphaAPIGroupVersionsDisabledByDefault = []schema.GroupVersion{ + lifecyclev1alpha1.SchemeGroupVersion, resourcev1alpha3.SchemeGroupVersion, schedulingapiv1alpha3.SchemeGroupVersion, storageapiv1alpha1.SchemeGroupVersion, diff --git a/pkg/controlplane/instance_test.go b/pkg/controlplane/instance_test.go index 0a3cf9c36ea..9937612aceb 100644 --- a/pkg/controlplane/instance_test.go +++ b/pkg/controlplane/instance_test.go @@ -78,6 +78,7 @@ import ( certificatesrest "k8s.io/kubernetes/pkg/registry/certificates/rest" corerest "k8s.io/kubernetes/pkg/registry/core/rest" discoveryrest "k8s.io/kubernetes/pkg/registry/discovery/rest" + lifecyclerest "k8s.io/kubernetes/pkg/registry/lifecycle/rest" networkingrest "k8s.io/kubernetes/pkg/registry/networking/rest" noderest "k8s.io/kubernetes/pkg/registry/node/rest" policyrest "k8s.io/kubernetes/pkg/registry/policy/rest" @@ -560,7 +561,8 @@ func TestGenericStorageProviders(t *testing.T) { schedulingrest.RESTStorageProvider, storagerest.RESTStorageProvider, appsrest.StorageProvider, - resourcerest.RESTStorageProvider: + resourcerest.RESTStorageProvider, + lifecyclerest.RESTStorageProvider: // all these are non-generic, but kube specific continue default: diff --git a/pkg/kubeapiserver/default_storage_factory_builder.go b/pkg/kubeapiserver/default_storage_factory_builder.go index e23ae0dc82e..cc42014cded 100644 --- a/pkg/kubeapiserver/default_storage_factory_builder.go +++ b/pkg/kubeapiserver/default_storage_factory_builder.go @@ -34,6 +34,7 @@ import ( api "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/apis/events" "k8s.io/kubernetes/pkg/apis/extensions" + "k8s.io/kubernetes/pkg/apis/lifecycle" "k8s.io/kubernetes/pkg/apis/networking" "k8s.io/kubernetes/pkg/apis/policy" "k8s.io/kubernetes/pkg/apis/resource" @@ -88,6 +89,8 @@ func NewStorageFactoryConfigEffectiveVersion(effectiveVersion basecompatibility. scheduling.Resource("workloads").WithVersion("v1beta1"), scheduling.Resource("podgroups").WithVersion("v1beta1"), scheduling.Resource("compositepodgroups").WithVersion("v1alpha3"), + lifecycle.Resource("evictions").WithVersion("v1alpha1"), + lifecycle.Resource("evictionrequests").WithVersion("v1alpha1"), } return &StorageFactoryConfig{ Serializer: legacyscheme.Codecs, diff --git a/pkg/kubectl/cmd/convert/import_known_versions.go b/pkg/kubectl/cmd/convert/import_known_versions.go index eafcd71251a..f62b3dade95 100644 --- a/pkg/kubectl/cmd/convert/import_known_versions.go +++ b/pkg/kubectl/cmd/convert/import_known_versions.go @@ -34,6 +34,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/events/install" _ "k8s.io/kubernetes/pkg/apis/flowcontrol/install" _ "k8s.io/kubernetes/pkg/apis/imagepolicy/install" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" _ "k8s.io/kubernetes/pkg/apis/networking/install" _ "k8s.io/kubernetes/pkg/apis/node/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" diff --git a/pkg/printers/internalversion/import_known_versions.go b/pkg/printers/internalversion/import_known_versions.go index c9c2674475e..5cedc65e2a6 100644 --- a/pkg/printers/internalversion/import_known_versions.go +++ b/pkg/printers/internalversion/import_known_versions.go @@ -30,6 +30,7 @@ import ( _ "k8s.io/kubernetes/pkg/apis/discovery/install" _ "k8s.io/kubernetes/pkg/apis/events/install" _ "k8s.io/kubernetes/pkg/apis/extensions/install" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" _ "k8s.io/kubernetes/pkg/apis/policy/install" _ "k8s.io/kubernetes/pkg/apis/rbac/install" _ "k8s.io/kubernetes/pkg/apis/resource/install" diff --git a/pkg/printers/internalversion/printers.go b/pkg/printers/internalversion/printers.go index 0fe885cb41e..031e0c2734d 100644 --- a/pkg/printers/internalversion/printers.go +++ b/pkg/printers/internalversion/printers.go @@ -20,6 +20,7 @@ import ( "bytes" "fmt" "net" + "slices" "sort" "strconv" "strings" @@ -37,6 +38,7 @@ import ( apiv1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" flowcontrolv1 "k8s.io/api/flowcontrol/v1" + lifecyclev1alpha1 "k8s.io/api/lifecycle/v1alpha1" networkingv1 "k8s.io/api/networking/v1" rbacv1beta1 "k8s.io/api/rbac/v1beta1" resourceapi "k8s.io/api/resource/v1" @@ -44,6 +46,7 @@ import ( schedulingv1alpha3 "k8s.io/api/scheduling/v1alpha3" schedulingv1beta1 "k8s.io/api/scheduling/v1beta1" storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" @@ -51,6 +54,7 @@ import ( "k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/certificate/csr" + "k8s.io/kubernetes/pkg/apis/lifecycle" "k8s.io/utils/ptr" podutil "k8s.io/kubernetes/pkg/api/pod" @@ -780,6 +784,34 @@ func AddHandlers(h printers.PrintHandler) { } _ = h.TableHandler(compositePodGroupColumnDefinitions, printCompositePodGroup) _ = h.TableHandler(compositePodGroupColumnDefinitions, printCompositePodGroupList) + + evictionRequestColumnDefinitions := []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, + {Name: "Target", Type: "string", Description: lifecyclev1alpha1.EvictionRequestSpec{}.SwaggerDoc()["target"]}, + {Name: "Target Type", Type: "string", Description: lifecyclev1alpha1.EvictionRequestSpec{}.SwaggerDoc()["target"]}, + {Name: "Status", Type: "string", Description: lifecyclev1alpha1.EvictionRequestStatus{}.SwaggerDoc()["conditions"]}, + {Name: "Requester", Type: "string", Description: lifecyclev1alpha1.EvictionRequestSpec{}.SwaggerDoc()["requester"]}, + {Name: "Intent", Type: "string", Description: lifecyclev1alpha1.EvictionRequestSpec{}.SwaggerDoc()["intent"]}, + {Name: "Age", Type: "string", Description: metav1.ObjectMeta{}.SwaggerDoc()["creationTimestamp"]}, + } + _ = h.TableHandler(evictionRequestColumnDefinitions, printEvictionRequest) + _ = h.TableHandler(evictionRequestColumnDefinitions, printEvictionRequestList) + + evictionColumnDefinitions := []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]}, + {Name: "Target", Type: "string", Description: lifecyclev1alpha1.EvictionSpec{}.SwaggerDoc()["target"]}, + {Name: "Target Type", Type: "string", Description: lifecyclev1alpha1.EvictionSpec{}.SwaggerDoc()["target"]}, + {Name: "Status", Type: "string", Description: lifecyclev1alpha1.EvictionStatus{}.SwaggerDoc()["conditions"]}, + {Name: "Active Responder", Type: "string", Description: lifecyclev1alpha1.TargetResponder{}.SwaggerDoc()["state"]}, + {Name: "Responder Status", Type: "string", Description: lifecyclev1alpha1.ResponderStatus{}.SwaggerDoc()[""]}, + {Name: "Responder Expected Finish", Type: "string", Description: lifecyclev1alpha1.ResponderStatus{}.SwaggerDoc()["expectedCompletionTime"]}, + {Name: "Requesters", Type: "string", Description: lifecyclev1alpha1.EvictionStatus{}.SwaggerDoc()["requesters"]}, + {Name: "Age", Type: "string", Description: metav1.ObjectMeta{}.SwaggerDoc()["creationTimestamp"]}, + {Name: "Responder Heartbeat", Type: "string", Priority: 1, Description: lifecyclev1alpha1.ResponderStatus{}.SwaggerDoc()["heartbeatTime"]}, + {Name: "Responder Status Message", Type: "string", Priority: 1, Description: lifecyclev1alpha1.ResponderStatus{}.SwaggerDoc()["message"]}, + } + _ = h.TableHandler(evictionColumnDefinitions, printEviction) + _ = h.TableHandler(evictionColumnDefinitions, printEvictionList) } // Pass ports=nil for all ports. @@ -3575,6 +3607,190 @@ func printCompositePodGroupList(list *scheduling.CompositePodGroupList, options return rows, nil } +func printEvictionRequest(obj *lifecycle.EvictionRequest, options printers.GenerateOptions) ([]metav1.TableRow, error) { + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: obj}, + } + var wideCells []interface{} + row.Cells = append(row.Cells, obj.Name) + + // resolve target + target := "" + targetType := "" + if obj.Spec.Target.Pod != nil { + target = obj.Spec.Target.Pod.Name + targetType = "Pod" + } + row.Cells = append(row.Cells, target, targetType) + + // resolve status + evictionStatus := resolveEvictionStatusConditions(obj.Status.ObservedGeneration, obj.Status.Conditions) + row.Cells = append(row.Cells, evictionStatus) + + // resolve requester, intent, age + row.Cells = append(row.Cells, obj.Spec.Requester, string(obj.Spec.Intent), translateTimestampSince(obj.CreationTimestamp)) + + row.Cells = append(row.Cells, wideCells...) + return []metav1.TableRow{row}, nil +} +func printEvictionRequestList(list *lifecycle.EvictionRequestList, options printers.GenerateOptions) ([]metav1.TableRow, error) { + rows := make([]metav1.TableRow, 0, len(list.Items)) + for i := range list.Items { + r, err := printEvictionRequest(&list.Items[i], options) + if err != nil { + return nil, err + } + rows = append(rows, r...) + } + return rows, nil +} + +func printEviction(obj *lifecycle.Eviction, options printers.GenerateOptions) ([]metav1.TableRow, error) { + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: obj}, + } + var wideCells []interface{} + row.Cells = append(row.Cells, obj.Name) + + // resolve target + target := "" + targetType := "" + if obj.Spec.Target.Pod != nil { + target = obj.Spec.Target.Pod.Name + targetType = "Pod" + } + row.Cells = append(row.Cells, target, targetType) + + // resolve status + evictionStatus := resolveEvictionStatusConditions(obj.Status.ObservedGeneration, obj.Status.Conditions) + row.Cells = append(row.Cells, evictionStatus) + + // resolve responder progress and find an active responder + totalResponders := len(obj.Status.TargetResponders) + processedOrActiveResponders := 0 + var lastActiveResponder, lastProcessedResponder *lifecycle.TargetResponder + for _, targetResponder := range obj.Status.TargetResponders { + switch targetResponder.State { + case lifecycle.ResponderStateCanceled, lifecycle.ResponderStateInterrupted, lifecycle.ResponderStateCompleted: + processedOrActiveResponders++ + lastProcessedResponder = &targetResponder + + case lifecycle.ResponderStateActive: + processedOrActiveResponders++ + lastActiveResponder = &targetResponder + } + } + if lastActiveResponder == nil { + // present last completed one if all have been processed + lastActiveResponder = lastProcessedResponder + } + activeResponder := "" + if lastActiveResponder != nil { + activeResponder = lastActiveResponder.Name + } + activeResponderWithCount := fmt.Sprintf("%s (%d/%d)", activeResponder, processedOrActiveResponders, totalResponders) + row.Cells = append(row.Cells, activeResponderWithCount) + + // resolve responder status and finish time + activeResponderExpectedCompletionTime := "" + responderStatus := "" + heartbeat := "" + message := "" + if lastActiveResponder != nil { + for _, responder := range obj.Status.Responders { + if responder.Name != lastActiveResponder.Name { + continue + } + + responderStatus = string(lastActiveResponder.State) + switch lastActiveResponder.State { + case lifecycle.ResponderStateActive: + if responder.StartTime != nil { + responderStatus = fmt.Sprintf("Started (%s ago)", translateTimestampSince(*responder.StartTime)) + if responder.ExpectedCompletionTime != nil { + if responder.ExpectedCompletionTime.After(time.Now()) { + activeResponderExpectedCompletionTime = fmt.Sprintf("in %s", translateTimestampUntil(*responder.ExpectedCompletionTime)) + } else { + // in case the estimate is wrong, or a kubelet is slow + activeResponderExpectedCompletionTime = fmt.Sprintf("%s ago", translateTimestampSince(*responder.ExpectedCompletionTime)) + } + } + } + case lifecycle.ResponderStateCanceled, lifecycle.ResponderStateInterrupted: + activeResponderExpectedCompletionTime = "-" + case lifecycle.ResponderStateCompleted: + if responder.CompletionTime != nil { + responderStatus = fmt.Sprintf("%s (%s ago)", lastActiveResponder.State, translateTimestampSince(*responder.CompletionTime)) + } + activeResponderExpectedCompletionTime = "-" + } + if options.Wide { + if responder.HeartbeatTime != nil { + heartbeat = fmt.Sprintf("%s ago", translateTimestampSince(*responder.HeartbeatTime)) + } + if msg := strings.TrimSpace(ptr.Deref(responder.Message, "")); len(msg) > 0 { + message = msg + } + } + break + } + } + + if options.Wide { + wideCells = append(wideCells, heartbeat, message) + } + row.Cells = append(row.Cells, responderStatus, activeResponderExpectedCompletionTime) + + // resolve requesters + var requesters []string + for _, requester := range obj.Status.Requesters { + if requester.Intent != lifecycle.RequesterIntentWithdrawn { + requesters = append(requesters, requester.Name) + } + } + slices.Sort(requesters) + requestersStr := "" + if len(obj.Status.Requesters) > 0 { + requestersStr = "" + } + if len(requesters) > 0 { + requestersStr = listWithMoreString(requesters[:1], len(requesters) > 1, len(requesters), 1) + } + row.Cells = append(row.Cells, requestersStr, translateTimestampSince(obj.CreationTimestamp)) + + row.Cells = append(row.Cells, wideCells...) + return []metav1.TableRow{row}, nil +} + +func printEvictionList(list *lifecycle.EvictionList, options printers.GenerateOptions) ([]metav1.TableRow, error) { + rows := make([]metav1.TableRow, 0, len(list.Items)) + for i := range list.Items { + r, err := printEviction(&list.Items[i], options) + if err != nil { + return nil, err + } + rows = append(rows, r...) + } + return rows, nil +} + +func resolveEvictionStatusConditions(observedGeneration *int64, conditions []metav1.Condition) string { + evictionStatus := "Pending" + if ptr.Deref(observedGeneration, 0) > 0 { + evictionStatus = "Progressing" + } + evicted := meta.FindStatusCondition(conditions, string(lifecycle.EvictionConditionTargetEvicted)) + failed := meta.FindStatusCondition(conditions, string(lifecycle.EvictionConditionFailed)) + isFailed := failed != nil && failed.Status == metav1.ConditionTrue + if isFailed { + evictionStatus = fmt.Sprintf("%s (%s)", failed.Type, failed.Reason) + } + if evicted != nil && evicted.Status == metav1.ConditionTrue { + evictionStatus = fmt.Sprintf("%s (%s)", evicted.Type, evicted.Reason) + } + return evictionStatus +} + func printBoolPtr(value *bool) string { if value != nil { return printBool(*value) diff --git a/pkg/printers/internalversion/printers_test.go b/pkg/printers/internalversion/printers_test.go index aec35c71a20..12c515614da 100644 --- a/pkg/printers/internalversion/printers_test.go +++ b/pkg/printers/internalversion/printers_test.go @@ -43,6 +43,7 @@ import ( api "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/apis/discovery" "k8s.io/kubernetes/pkg/apis/flowcontrol" + "k8s.io/kubernetes/pkg/apis/lifecycle" "k8s.io/kubernetes/pkg/apis/networking" nodeapi "k8s.io/kubernetes/pkg/apis/node" "k8s.io/kubernetes/pkg/apis/policy" @@ -8617,3 +8618,657 @@ func TestPrintResourcePoolStatusRequest(t *testing.T) { } } } + +func TestPrintEvictionRequest(t *testing.T) { + now := time.Now().UTC().AddDate(0, 0, -3) + twoDaysAgo := now.AddDate(0, 0, -2) + + eviction := lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod1", + Namespace: "ns1", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod1", + UID: "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{}, + }, + } + + tests := []struct { + expected []metav1.TableRow + }{ + { + expected: []metav1.TableRow{ + // Columns: Name, Target, Target Type, Status, Requester, Intent, Age + {Cells: []interface{}{"pod-1-pod1", "pod1", "Pod", "Progressing", "drain.foo.com/bar", "Eviction", "5d"}}, + }, + }, + } + + for _, test := range tests { + rows, err := printEvictionRequest(&eviction, printers.GenerateOptions{}) + if err != nil { + t.Fatalf("Error generating table rows for EvictionRequest: %#v", err) + } + rows[0].Object.Object = nil + if !reflect.DeepEqual(test.expected, rows) { + t.Errorf("mismatch: %s", cmp.Diff(test.expected, rows)) + } + } +} + +func TestPrintEvictionRequestList(t *testing.T) { + now := time.Now().UTC().AddDate(0, 0, -3) + dayAgo := now.AddDate(0, 0, -1) + twoDaysAgo := now.AddDate(0, 0, -2) + evictionRequestList := lifecycle.EvictionRequestList{ + Items: []lifecycle.EvictionRequest{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod1", + Namespace: "ns1", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod1", + UID: "bc134542-aa19-4361-8fe2-cf18f78848c0", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: dayAgo}, Reason: string(lifecycle.EvictionConditionReasonPodDeleted), Message: "Pod was successfully deleted."}, + }, + }, + }, + + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod2", + Namespace: "ns2", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod2", + UID: "5e153f5b-e420-41b4-9040-8f3b51cf2162", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonNoFurtherResponder), Message: "Canceled because there is no further responder."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod3", + Namespace: "ns3", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod3", + UID: "68018c4c-bcca-4465-964a-66d8a1626686", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentWithdrawn, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonCanceledDueToNoRequesters), Message: "Canceled due to no requesters."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod4", + Namespace: "ns4", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod4", + UID: "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "EvictionInProgress", Message: "Pod is being evicted."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod5", + Namespace: "ns5", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod5", + UID: "9f4a1620-0e97-4246-b304-5969b2104377", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "Canceled", Message: "Eviction failed due to cancelation."}, + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonEvictionInvalid), Message: "Pod pod6 was not found."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod6", + Namespace: "ns6", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + Name: "pod6", + UID: "e968f679-523b-4e63-9417-0cd9ae13a0b2", + }, + }, + Requester: "drain.foo.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + }, + }, + } + + // Columns: Name, Target, Target Type, Status, Requester, Intent, Age + expected := []metav1.TableRow{ + {Cells: []interface{}{"pod-1-pod1", "pod1", "Pod", "TargetEvicted (PodDeleted)", "drain.foo.com/bar", "Eviction", "5d"}}, + {Cells: []interface{}{"pod-1-pod2", "pod2", "Pod", "Failed (NoFurtherResponder)", "drain.foo.com/bar", "Eviction", "5d"}}, + {Cells: []interface{}{"pod-1-pod3", "pod3", "Pod", "Failed (CanceledDueToNoRequesters)", "drain.foo.com/bar", "Withdrawn", "5d"}}, + {Cells: []interface{}{"pod-1-pod4", "pod4", "Pod", "Progressing", "drain.foo.com/bar", "Eviction", "5d"}}, + {Cells: []interface{}{"pod-1-pod5", "pod5", "Pod", "Failed (EvictionInvalid)", "drain.foo.com/bar", "Eviction", "5d"}}, + {Cells: []interface{}{"pod-1-pod6", "pod6", "Pod", "Pending", "drain.foo.com/bar", "Eviction", "5d"}}, + } + + rows, err := printEvictionRequestList(&evictionRequestList, printers.GenerateOptions{}) + if err != nil { + t.Fatalf("Error generating table rows for EvictionRequestList: %#v", err) + } + for i := range rows { + rows[i].Object.Object = nil + } + + if !reflect.DeepEqual(expected, rows) { + t.Errorf("mismatch: %s", cmp.Diff(expected, rows)) + } +} + +func TestPrintEviction(t *testing.T) { + now := time.Now().UTC().AddDate(0, 0, -3) + daysLater := now.AddDate(0, 0, 5).Add(time.Minute) + dayAgo := now.AddDate(0, 0, -1) + twoDaysAgo := now.AddDate(0, 0, -2) + + eviction := lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod1", + Namespace: "ns1", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod1", + UID: "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateCompleted}, + {Name: "responder2", State: lifecycle.ResponderStateActive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: &metav1.Time{Time: dayAgo}, Message: new("completed message")}, + {Name: "responder2", HeartbeatTime: &metav1.Time{Time: now}, ExpectedCompletionTime: &metav1.Time{Time: daysLater}, StartTime: &metav1.Time{Time: now}, CompletionTime: nil, Message: new("migrating pod1 message")}, + }, + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + Conditions: []metav1.Condition{}, + }, + } + + tests := []struct { + options printers.GenerateOptions + expected []metav1.TableRow + }{ + { + options: printers.GenerateOptions{Wide: false}, + expected: []metav1.TableRow{ + // Columns: Name, Target, Target Type, Status, Active Responder, Responder Status, Responder Expected Finish, Requesters, Age + {Cells: []interface{}{"pod-1-pod1", "pod1", "Pod", "Progressing", "responder2 (2/2)", "Started (3d ago)", "in 2d", "drain.foo.com/bar", "5d"}}, + }, + }, + { + options: printers.GenerateOptions{Wide: true}, + expected: []metav1.TableRow{ + // Columns: Name, Target, Target Type, Status, Active Responder, Responder Status, Responder Expected Finish, Requesters, Age, Responder Status Message, Responder Heartbeat + {Cells: []interface{}{"pod-1-pod1", "pod1", "Pod", "Progressing", "responder2 (2/2)", "Started (3d ago)", "in 2d", "drain.foo.com/bar", "5d", "3d ago", "migrating pod1 message"}}, + }, + }, + } + + for _, test := range tests { + rows, err := printEviction(&eviction, test.options) + if err != nil { + t.Fatalf("Error generating table rows for Eviction: %#v", err) + } + rows[0].Object.Object = nil + if !reflect.DeepEqual(test.expected, rows) { + t.Errorf("mismatch: %s", cmp.Diff(test.expected, rows)) + } + } +} + +func TestPrintEvictionList(t *testing.T) { + now := time.Now().UTC().AddDate(0, 0, -3) + daysLater := now.AddDate(0, 0, 5).Add(time.Minute) + dayAgo := now.AddDate(0, 0, -1) + twoDaysAgo := now.AddDate(0, 0, -2) + evictionList := lifecycle.EvictionList{ + Items: []lifecycle.Eviction{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod1", + Namespace: "ns1", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod1", + UID: "bc134542-aa19-4361-8fe2-cf18f78848c0", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateCompleted}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: &metav1.Time{Time: dayAgo}, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: &metav1.Time{Time: dayAgo}, Message: new("migrated1")}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: dayAgo}, Reason: string(lifecycle.EvictionConditionReasonPodDeleted), Message: "Pod was successfully deleted."}, + }, + }, + }, + + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod2", + Namespace: "ns2", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod2", + UID: "5e153f5b-e420-41b4-9040-8f3b51cf2162", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateInterrupted}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: &metav1.Time{Time: dayAgo}, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: nil, Message: new("running for a long time")}}, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonNoFurtherResponder), Message: "Canceled because there is no further responder."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod3", + Namespace: "ns3", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod3", + UID: "a4ea9433-da6a-4bfc-9033-296488ba12be", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateCompleted}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: &metav1.Time{Time: dayAgo}, Message: new("completed message")}}, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonNoFurtherResponder), Message: "Canceled because there is no further responder."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod4", + Namespace: "ns4", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod4", + UID: "68018c4c-bcca-4465-964a-66d8a1626686", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentWithdrawn, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder3", State: lifecycle.ResponderStateCanceled}, + {Name: "responder4", State: lifecycle.ResponderStateInactive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder3", HeartbeatTime: &metav1.Time{Time: now}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: now}, CompletionTime: nil, Message: new("running3")}, + {Name: "responder4", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonCanceledDueToNoRequesters), Message: "Canceled due to no requesters."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod5", + Namespace: "ns5", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod5", + UID: "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateCompleted}, + {Name: "responder2", State: lifecycle.ResponderStateActive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: &metav1.Time{Time: dayAgo}, Message: new("completed2")}, + {Name: "responder2", HeartbeatTime: &metav1.Time{Time: now}, ExpectedCompletionTime: &metav1.Time{Time: daysLater}, StartTime: &metav1.Time{Time: now}, CompletionTime: nil, Message: new("running2")}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "EvictionInProgress", Message: "Pod is being evicted."}, + }, + }, + }, + + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod6", + Namespace: "ns6", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod6", + UID: "aac2a982-2176-4770-8c8c-bcdf51b24fde", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1", State: lifecycle.ResponderStateCompleted}, + {Name: "responder2", State: lifecycle.ResponderStateActive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1", HeartbeatTime: &metav1.Time{Time: dayAgo}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: twoDaysAgo}, CompletionTime: &metav1.Time{Time: dayAgo}, Message: new("completed2")}, + {Name: "responder2", HeartbeatTime: &metav1.Time{Time: now}, ExpectedCompletionTime: &metav1.Time{Time: dayAgo}, StartTime: &metav1.Time{Time: now}, CompletionTime: nil, Message: new("running2")}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "EvictionInProgress", Message: "Pod is being evicted."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod7", + Namespace: "ns7", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod7", + UID: "707ba618-ffd6-4797-b503-17af1c7f4d98", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "rescheduler.bar.com", + Intent: lifecycle.RequesterIntentEviction, + }, + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder3", State: lifecycle.ResponderStateActive}, + {Name: "responder4", State: lifecycle.ResponderStateInactive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder3", HeartbeatTime: &metav1.Time{Time: now}, ExpectedCompletionTime: nil, StartTime: &metav1.Time{Time: now}, CompletionTime: nil, Message: new("running3")}, + {Name: "responder4", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + }, + + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "EvictionInProgress", Message: "Pod is being evicted."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod8", + Namespace: "ns8", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod8", + UID: "30741366-8d1f-4385-84ff-0f2e7ba0305f", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder3", State: lifecycle.ResponderStateInactive}, + {Name: "responder4", State: lifecycle.ResponderStateInactive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder3", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + {Name: "responder4", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "WaitingForResponder", Message: "Waiting for an responder."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod9", + Namespace: "ns9", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod9", + UID: "9f4a1620-0e97-4246-b304-5969b2104377", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + { + Name: "drain.foo.com/bar", + Intent: lifecycle.RequesterIntentEviction, + }, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder3", State: lifecycle.ResponderStateInactive}, + {Name: "responder4", State: lifecycle.ResponderStateInactive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder3", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + {Name: "responder4", HeartbeatTime: nil, ExpectedCompletionTime: nil, StartTime: nil, CompletionTime: nil, Message: nil}, + }, + Conditions: []metav1.Condition{ + {Type: string(lifecycle.EvictionConditionTargetEvicted), Status: metav1.ConditionFalse, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: "Canceled", Message: "Eviction failed due to cancelation."}, + {Type: string(lifecycle.EvictionConditionFailed), Status: metav1.ConditionTrue, ObservedGeneration: 1, LastTransitionTime: metav1.Time{Time: now}, Reason: string(lifecycle.EvictionConditionReasonEvictionInvalid), Message: "Pod pod6 was not found."}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1-pod10", + Namespace: "ns10", + CreationTimestamp: metav1.Time{Time: twoDaysAgo}, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + Name: "pod10", + UID: "e968f679-523b-4e63-9417-0cd9ae13a0b2", + }, + }, + }, + }, + }, + } + + // Columns: Name, Target, Target Type, Status, Active Responder, Responder Status, Responder Expected Finish, Requesters, Age + expected := []metav1.TableRow{ + {Cells: []interface{}{"pod-1-pod1", "pod1", "Pod", "TargetEvicted (PodDeleted)", "responder1 (1/1)", "Completed (4d ago)", "-", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod2", "pod2", "Pod", "Failed (NoFurtherResponder)", "responder1 (1/1)", "Interrupted", "-", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod3", "pod3", "Pod", "Failed (NoFurtherResponder)", "responder1 (1/1)", "Completed (4d ago)", "-", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod4", "pod4", "Pod", "Failed (CanceledDueToNoRequesters)", "responder3 (1/2)", "Canceled", "-", "", "5d"}}, + {Cells: []interface{}{"pod-1-pod5", "pod5", "Pod", "Progressing", "responder2 (2/2)", "Started (3d ago)", "in 2d", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod6", "pod6", "Pod", "Progressing", "responder2 (2/2)", "Started (3d ago)", "4d ago", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod7", "pod7", "Pod", "Progressing", "responder3 (1/2)", "Started (3d ago)", "", "drain.foo.com/bar + 1 more...", "5d"}}, + {Cells: []interface{}{"pod-1-pod8", "pod8", "Pod", "Progressing", " (0/2)", "", "", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod9", "pod9", "Pod", "Failed (EvictionInvalid)", " (0/2)", "", "", "drain.foo.com/bar", "5d"}}, + {Cells: []interface{}{"pod-1-pod10", "pod10", "Pod", "Pending", " (0/0)", "", "", "", "5d"}}, + } + + rows, err := printEvictionList(&evictionList, printers.GenerateOptions{}) + if err != nil { + t.Fatalf("Error generating table rows for EvictionList: %#v", err) + } + for i := range rows { + rows[i].Object.Object = nil + } + + if !reflect.DeepEqual(expected, rows) { + t.Errorf("mismatch: %s", cmp.Diff(expected, rows)) + } +} diff --git a/pkg/registry/lifecycle/eviction/doc.go b/pkg/registry/lifecycle/eviction/doc.go new file mode 100644 index 00000000000..4149e80ab89 --- /dev/null +++ b/pkg/registry/lifecycle/eviction/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 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 eviction diff --git a/pkg/registry/lifecycle/eviction/storage/storage.go b/pkg/registry/lifecycle/eviction/storage/storage.go new file mode 100644 index 00000000000..5ecda684471 --- /dev/null +++ b/pkg/registry/lifecycle/eviction/storage/storage.go @@ -0,0 +1,107 @@ +/* +Copyright 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" + + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + 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" + lifecycleapi "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/lifecycle/eviction" + "k8s.io/utils/clock" +) + +// REST implements a RESTStorage for evictions against etcd +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against evictions. +func NewREST(optsGetter generic.RESTOptionsGetter, clock clock.PassiveClock) (*REST, *StatusREST, error) { + strategy := eviction.NewStrategy(clock) + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &lifecycleapi.Eviction{} }, + NewListFunc: func() runtime.Object { return &lifecycleapi.EvictionList{} }, + DefaultQualifiedResource: lifecycleapi.Resource("evictions"), + SingularQualifiedResource: lifecycleapi.Resource("eviction"), + + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + ResetFieldsStrategy: strategy, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter} + if err := store.CompleteWithOptions(options); err != nil { + return nil, nil, err + } + + statusStrategy := eviction.NewStatusStrategy(strategy) + + statusStore := *store + statusStore.UpdateStrategy = statusStrategy + statusStore.ResetFieldsStrategy = statusStrategy + + return &REST{store}, &StatusREST{store: &statusStore}, nil +} + +// StatusREST implements the REST endpoint for changing the status of evictions. +type StatusREST struct { + store *genericregistry.Store +} + +// New creates a new Eviction object. +func (r *StatusREST) New() runtime.Object { + return &lifecycleapi.Eviction{} +} + +// Destroy cleans up resources on shutdown. +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() +} + +func (r *StatusREST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return r.store.ConvertToTable(ctx, object, tableOptions) +} diff --git a/pkg/registry/lifecycle/eviction/storage/storage_test.go b/pkg/registry/lifecycle/eviction/storage/storage_test.go new file mode 100644 index 00000000000..8d134297f96 --- /dev/null +++ b/pkg/registry/lifecycle/eviction/storage/storage_test.go @@ -0,0 +1,274 @@ +/* +Copyright 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" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + 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/lifecycle" + "k8s.io/kubernetes/pkg/registry/registrytest" + testing2 "k8s.io/utils/clock/testing" +) + +const validUID = "8057f54d-455d-4b25-90c6-92a919cff10a" + +func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) { + clock := testing2.NewFakePassiveClock(time.Now()) + etcdStorage, server := registrytest.NewEtcdStorageForResource(t, lifecycle.SchemeGroupVersion.WithResource("evictions").GroupResource()) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "evictions", + } + + evictionStorage, evictionStatusStorage, err := NewREST(restOptions, clock) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return evictionStorage, evictionStatusStorage, server +} + +func tester(t *testing.T, storage *REST) *genericregistrytest.Tester { + test := genericregistrytest.New(t, storage.Store) + requestInfo := &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictions", + } + test.SetRequestInfo(requestInfo) + return test +} + +func newValidEviction() *lifecycle.Eviction { + return &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + }, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + {Name: "requester-1.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + {Name: "requester-2.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1.example.com/bar", Priority: new(int32(1000)), State: lifecycle.ResponderStateInactive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1.example.com/bar"}, + }, + }, + } +} + +func TestCreate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + validEviction := newValidEviction() + validEviction.ObjectMeta = metav1.ObjectMeta{} + + invalidEviction := newValidEviction() + invalidEviction.ObjectMeta = metav1.ObjectMeta{Name: "-foo"} + test.TestCreate( + validEviction, + invalidEviction, + ) +} + +func TestUpdate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + validEviction := newValidEviction() + test.TestUpdate( + validEviction, + func(obj runtime.Object) runtime.Object { + object := obj.(*lifecycle.Eviction) + object.ObjectMeta.Annotations = map[string]string{"foo": "bar"} + return object + }, + // invalid updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*lifecycle.Eviction) + object.Spec.Target.Pod.Name = "bar" + return object + }, + ) +} +func TestDelete(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestDelete(newValidEviction()) +} + +func TestGet(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestGet(newValidEviction()) +} + +func TestList(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestList(newValidEviction()) +} + +func TestWatch(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestWatch( + newValidEviction(), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"x": "y"}, + }, + // matching fields + []fields.Set{}, + // not matching fields + []fields.Set{ + {"metadata.name": "xyz"}, + {"name": "foo"}, + }, + ) +} + +func TestStatusUpdate(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + eviction := newValidEviction() + eviction.Status = lifecycle.EvictionStatus{} + + ctx := evictionContext() + key, err := storage.KeyFunc(ctx, "foo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result := &lifecycle.Eviction{} + if err := storage.Storage.Create(ctx, key, eviction, result, 0, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Status.TargetResponders) != 0 { + t.Errorf("we expected .status.targetResponders to be empty but it was %v", result.Status.TargetResponders) + } + evictionUpdate := newValidEviction() + evictionUpdate.ObjectMeta = result.ObjectMeta + evictionUpdate.Labels = map[string]string{"foo": "bar"} + evictionUpdate.Spec.Target.Pod.Name = "bax" + evictionUpdate.Status.TargetResponders = []lifecycle.TargetResponder{ + {Name: "responder1.example.com/bar", Priority: new(int32(1000)), State: lifecycle.ResponderStateActive}, + } + evictionUpdate.Status.Responders = []lifecycle.ResponderStatus{ + {Name: "responder1.example.com/bar", StartTime: new(metav1.Now())}, + } + + if _, _, err := statusStorage.Update(ctx, evictionUpdate.Name, rest.DefaultUpdatedObjectInfo(evictionUpdate), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + obj, err := storage.Get(ctx, evictionUpdate.Name, &metav1.GetOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result = obj.(*lifecycle.Eviction) + if len(result.Labels) != 0 { + t.Errorf("we expected .status.labels to be empty but it was %v", result.Labels) + } + if result.Spec.Target.Pod.Name != "foo.pod" { + t.Errorf("we expected .spec.target.pod.name to not be updated but it was updated to %v", result.Spec.Target.Pod.Name) + } + if len(result.Status.TargetResponders) != 1 { + t.Errorf("we expected .status.targetResponders to be updated to but it was %v", result.Status.TargetResponders) + } +} + +func TestGenerationNumber(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + eviction := newValidEviction() + eviction.Generation = 100 + eviction.Status.ObservedGeneration = new(int64(10)) + ctx := evictionContext() + resultObj, err := storage.Create(ctx, eviction, rest.ValidateAllObjectFunc, &metav1.CreateOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result, _ := resultObj.(*lifecycle.Eviction) + + // Generation initialization + if result.Generation != 1 || result.Status.ObservedGeneration != nil { + t.Fatalf("Unexpected generation number %v, status generation %v", result.Generation, result.Status.ObservedGeneration) + } + + // Updates to status should not increment either spec or status generation numbers + result.Status.Conditions = append(result.Status.Conditions, metav1.Condition{Type: "Test", Status: "True", LastTransitionTime: metav1.Now(), Reason: "Reason"}) + if _, _, err := statusStorage.Update(ctx, result.Name, rest.DefaultUpdatedObjectInfo(result), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}); err != nil { + t.Errorf("unexpected error: %v", err) + } + resultObj, err = storage.Get(ctx, result.Name, &metav1.GetOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + result, _ = resultObj.(*lifecycle.Eviction) + if result.Generation != 1 || result.Status.ObservedGeneration != nil { + t.Fatalf("Unexpected generation number, spec: %v, status: %v", result.Generation, result.Status.ObservedGeneration) + } +} + +func evictionContext() context.Context { + ctx := genericapirequest.WithNamespace(genericapirequest.NewContext(), metav1.NamespaceDefault) + ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictions", + }) + return ctx +} diff --git a/pkg/registry/lifecycle/eviction/strategy.go b/pkg/registry/lifecycle/eviction/strategy.go new file mode 100644 index 00000000000..c062e7235ca --- /dev/null +++ b/pkg/registry/lifecycle/eviction/strategy.go @@ -0,0 +1,182 @@ +/* +Copyright 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 eviction + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/kubernetes/pkg/apis/lifecycle/validation" + "k8s.io/utils/clock" +) + +// evictionStrategy is the default logic that applies when creating and updating Eviction objects. +type evictionStrategy struct { + rest.DeclarativeValidation + names.NameGenerator + clock clock.PassiveClock +} + +func NewStrategy(clock clock.PassiveClock) *evictionStrategy { + return &evictionStrategy{ + rest.DeclarativeValidation{Scheme: legacyscheme.Scheme}, + names.SimpleNameGenerator, + clock, + } +} + +var _ = rest.ResetFieldsStrategy(&evictionStrategy{}) + +func (*evictionStrategy) NamespaceScoped() bool { + return true +} + +// GetResetFields returns the set of fields that get reset by the strategy +// and should not be modified by the user. +func (*evictionStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "lifecycle.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("status"), + ), + } + return fields +} + +// PrepareForCreate clears fields that are not allowed to be set by end users on creation. +func (*evictionStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { + eviction := obj.(*lifecycle.Eviction) + eviction.Status = lifecycle.EvictionStatus{} + eviction.Generation = 1 +} + +// PrepareForUpdate clears fields that are not allowed to be set by end users on update. +func (*evictionStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + oldEviction := old.(*lifecycle.Eviction) + newEviction := obj.(*lifecycle.Eviction) + newEviction.Status = oldEviction.Status + + // Spec updates bump the generation. + if !apiequality.Semantic.DeepEqual(oldEviction.Spec, newEviction.Spec) { + newEviction.Generation = oldEviction.Generation + 1 + } +} + +// Validate validates a new Eviction. +func (s *evictionStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + eviction := obj.(*lifecycle.Eviction) + + allErrs := validation.ValidateEviction(eviction) + return allErrs +} + +func (*evictionStrategy) DeclarativeValidationConfig(ctx context.Context, obj, oldObj runtime.Object) rest.DeclarativeValidationConfig { + return rest.DeclarativeValidationConfig{} +} + +func (*evictionStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (*evictionStrategy) Canonicalize(obj runtime.Object) { +} + +func (*evictionStrategy) AllowCreateOnUpdate(ctx context.Context) bool { + return false +} + +// ValidateUpdate is the default update validation for an end user. +func (s *evictionStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + var allErrs field.ErrorList + eviction := obj.(*lifecycle.Eviction) + oldEviction := old.(*lifecycle.Eviction) + + allErrs = validation.ValidateEvictionUpdate(eviction, oldEviction) + return allErrs +} + +func (*evictionStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (*evictionStrategy) AllowUnconditionalUpdate(ctx context.Context) bool { + return false +} + +// evictionStatusStrategy is the default logic invoked when updating object status. +type evictionStatusStrategy struct { + *evictionStrategy +} + +var _ = rest.ResetFieldsStrategy(&evictionStatusStrategy{}) + +func NewStatusStrategy(strategy *evictionStrategy) *evictionStatusStrategy { + return &evictionStatusStrategy{ + strategy, + } +} + +// GetResetFields returns the set of fields that get reset by the strategy +// and should not be modified by the user. +func (*evictionStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + return map[fieldpath.APIVersion]*fieldpath.Set{ + "lifecycle.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("spec"), + fieldpath.MakePathOrDie("metadata"), + ), + } +} + +// PrepareForUpdate clears fields that are not allowed to be set by end users on update of status +func (*evictionStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newEviction := obj.(*lifecycle.Eviction) + oldEviction := old.(*lifecycle.Eviction) + newEviction.Spec = oldEviction.Spec + // Status updates should not include metadata update privileges, also, + // the eviction-controller should be responsible for the labels + // and not the responders - let's not promote label updates. + metav1.ResetObjectMetaForStatus(&newEviction.ObjectMeta, &oldEviction.ObjectMeta) +} + +func (*evictionStatusStrategy) AllowCreateOnUpdate(ctx context.Context) bool { + return false +} + +// ValidateUpdate is the default update validation for an end user updating status +func (s *evictionStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + allErrs := validation.ValidateEvictionStatusUpdate(obj.(*lifecycle.Eviction), old.(*lifecycle.Eviction), validation.EvictionStatusValidationOptions{ + Clock: s.clock, + }) + return allErrs +} + +// WarningsOnUpdate returns warnings for the given update. +func (*evictionStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (*evictionStatusStrategy) AllowUnconditionalUpdate(ctx context.Context) bool { + return false +} diff --git a/pkg/registry/lifecycle/eviction/strategy_test.go b/pkg/registry/lifecycle/eviction/strategy_test.go new file mode 100644 index 00000000000..af50dbad833 --- /dev/null +++ b/pkg/registry/lifecycle/eviction/strategy_test.go @@ -0,0 +1,296 @@ +/* +Copyright 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 eviction + +import ( + "testing" + "time" + + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/lifecycle" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" + testing2 "k8s.io/utils/clock/testing" +) + +const validUID = "e02c09a7-9226-4ce8-bdd2-fd69c87ae9ed" + +func TestEvictionStrategy_ResetFields(t *testing.T) { + strategy := NewStrategy(nil) + for _, fields := range strategy.GetResetFields() { + if !fields.Has(fieldpath.MakePathOrDie("status")) { + t.Errorf("status should be reset on creation and update") + } + } +} + +func TestEvictionStrategy(t *testing.T) { + clock := testing2.NewFakePassiveClock(time.Now()) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictions", + IsResourceRequest: true, + Verb: "create", + }) + strategy := NewStrategy(clock) + + if !strategy.NamespaceScoped() { + t.Errorf("Eviction must be namespace scoped") + } + if strategy.AllowCreateOnUpdate(ctx) { + t.Errorf("Eviction should not allow create on update") + } + if len(strategy.GenerateName("test")) <= len("test") { + t.Errorf("Eviction should implement name generation") + } + if len(strategy.WarningsOnCreate(ctx, nil)) != 0 { + t.Errorf("Eviction warnings on create are expected to be empty") + } + + eviction := &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo"}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + {Name: "requester.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "test", Priority: new(int32(1000)), State: lifecycle.ResponderStateInactive}, + }, + }, + } + + strategy.PrepareForCreate(ctx, eviction) + if eviction.Generation != int64(1) { + t.Error("Eviction metadata.generation should be set to 1") + } + if len(eviction.Status.TargetResponders) != 0 { + t.Error("Eviction should not allow setting status.targetResponders on create") + } + if eviction.Status.ObservedGeneration != nil { + t.Error("Eviction should not allow setting status.observedGeneration on create") + } + errs := strategy.Validate(ctx, eviction) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} + +func TestEvictionStrategy_Update(t *testing.T) { + clock := testing2.NewFakePassiveClock(time.Now()) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictions", + IsResourceRequest: true, + Verb: "update", + }) + ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "other-user"}) + strategy := NewStrategy(clock) + + if len(strategy.WarningsOnUpdate(ctx, nil, nil)) != 0 { + t.Errorf("Eviction warnings on update are expected to be empty") + } + + oldEviction := &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 1, ResourceVersion: "2", UID: "3fa927b6-a79d-43e8-9e49-f4f488c275ad"}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + Requesters: []lifecycle.Requester{ + {Name: "requester-1.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + {Name: "requester-2.example.com/bar", Intent: lifecycle.RequesterIntentWithdrawn}, + }, + }, + } + + newEviction := &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", ResourceVersion: "2"}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "bar.pod", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(10)), + Requesters: []lifecycle.Requester{ + {Name: "requester-1.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + {Name: "requester-2.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "test", Priority: new(int32(1000)), State: lifecycle.ResponderStateCanceled}, + }, + }, + } + + strategy.PrepareForUpdate(ctx, newEviction, oldEviction) + if newEviction.Generation != int64(2) { + t.Error("Eviction metadata.generation should be set to 2") + } + if len(newEviction.Status.TargetResponders) != 0 { + t.Error("Eviction should not allow setting status.targetResponders on update") + } + errs := strategy.ValidateUpdate(ctx, newEviction, oldEviction) + if len(errs) == 0 { + t.Errorf("Expected a validation error") + } + newEviction.UID = oldEviction.UID + errs = strategy.ValidateUpdate(ctx, newEviction, oldEviction) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} + +func TestEvictionStatusStrategy_ResetFields(t *testing.T) { + strategy := NewStrategy(nil) + statusStrategy := NewStatusStrategy(strategy) + for _, fields := range statusStrategy.GetResetFields() { + if !fields.Has(fieldpath.MakePathOrDie("spec")) { + t.Errorf("spec should be reset on status update") + } + if !fields.Has(fieldpath.MakePathOrDie("metadata")) { + t.Errorf("metadata should be reset on status update") + } + } +} + +func TestEvictionStatusStrategy(t *testing.T) { + clock := testing2.NewFakePassiveClock(time.Now()) + strategy := NewStatusStrategy(NewStrategy(clock)) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictions", + IsResourceRequest: true, + Verb: "update", + Subresource: "status", + }) + + oldEviction := &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 2, ResourceVersion: "2", + Annotations: map[string]string{"test": "true"}, + Labels: map[string]string{"foo": "bar"}}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(2)), + Conditions: []metav1.Condition{ + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + }, + }, + } + + newEviction := &lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 2, ResourceVersion: "2", + Annotations: map[string]string{"test": "false"}, + Labels: map[string]string{"foo": "baz"}}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.update", + }, + }, + }, + Status: lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(2)), + Requesters: []lifecycle.Requester{ + {Name: "requester-1.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + {Name: "requester-2.example.com/bar", Intent: lifecycle.RequesterIntentEviction}, + }, + TargetResponders: []lifecycle.TargetResponder{ + {Name: "responder1.example.com/bar", Priority: new(int32(1000)), State: lifecycle.ResponderStateActive}, + }, + Responders: []lifecycle.ResponderStatus{ + {Name: "responder1.example.com/bar", Message: new("test message"), StartTime: new(metav1.Now())}, + }, + Conditions: []metav1.Condition{ + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + }, + }, + } + + strategy.PrepareForUpdate(ctx, newEviction, oldEviction) + if newEviction.Spec.Target.Pod.Name != "foo.pod" { + t.Error("Eviction spec.target.pod.name should not be updated") + } + if newEviction.Labels["foo"] != "bar" { + t.Error("Eviction should not allow changing labels") + } + if newEviction.Annotations["test"] != "true" { + t.Error("Eviction should not allow changing annotations") + } + errs := strategy.ValidateUpdate(ctx, newEviction, oldEviction) + if len(errs) == 0 { + t.Errorf("Expected a validation error") + } + newEviction.Status.Conditions = newEviction.Status.Conditions[:1] + errs = strategy.ValidateUpdate(ctx, newEviction, oldEviction) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} diff --git a/pkg/registry/lifecycle/evictionrequest/doc.go b/pkg/registry/lifecycle/evictionrequest/doc.go new file mode 100644 index 00000000000..d214d5fbab5 --- /dev/null +++ b/pkg/registry/lifecycle/evictionrequest/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 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 evictionrequest diff --git a/pkg/registry/lifecycle/evictionrequest/storage/storage.go b/pkg/registry/lifecycle/evictionrequest/storage/storage.go new file mode 100644 index 00000000000..c20ff1424d2 --- /dev/null +++ b/pkg/registry/lifecycle/evictionrequest/storage/storage.go @@ -0,0 +1,106 @@ +/* +Copyright 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" + + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + 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" + lifecycleapi "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/kubernetes/pkg/printers" + printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" + printerstorage "k8s.io/kubernetes/pkg/printers/storage" + "k8s.io/kubernetes/pkg/registry/lifecycle/evictionrequest" +) + +// REST implements a RESTStorage for evictionrequests against etcd +type REST struct { + *genericregistry.Store +} + +// NewREST returns a RESTStorage object that will work against evictionrequests. +func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) { + strategy := evictionrequest.NewStrategy() + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &lifecycleapi.EvictionRequest{} }, + NewListFunc: func() runtime.Object { return &lifecycleapi.EvictionRequestList{} }, + DefaultQualifiedResource: lifecycleapi.Resource("evictionrequests"), + SingularQualifiedResource: lifecycleapi.Resource("evictionrequest"), + + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + ResetFieldsStrategy: strategy, + + TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, + } + options := &generic.StoreOptions{RESTOptions: optsGetter} + if err := store.CompleteWithOptions(options); err != nil { + return nil, nil, err + } + + statusStrategy := evictionrequest.NewStatusStrategy(strategy) + + statusStore := *store + statusStore.UpdateStrategy = statusStrategy + statusStore.ResetFieldsStrategy = statusStrategy + + return &REST{store}, &StatusREST{store: &statusStore}, nil +} + +// StatusREST implements the REST endpoint for changing the status of evictionrequests. +type StatusREST struct { + store *genericregistry.Store +} + +// New creates a new EvictionRequest object. +func (r *StatusREST) New() runtime.Object { + return &lifecycleapi.EvictionRequest{} +} + +// Destroy cleans up resources on shutdown. +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() +} + +func (r *StatusREST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return r.store.ConvertToTable(ctx, object, tableOptions) +} diff --git a/pkg/registry/lifecycle/evictionrequest/storage/storage_test.go b/pkg/registry/lifecycle/evictionrequest/storage/storage_test.go new file mode 100644 index 00000000000..1159491e8f1 --- /dev/null +++ b/pkg/registry/lifecycle/evictionrequest/storage/storage_test.go @@ -0,0 +1,263 @@ +/* +Copyright 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" + "testing" + + "k8s.io/utils/ptr" + + 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/authentication/user" + 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/lifecycle" + "k8s.io/kubernetes/pkg/registry/registrytest" +) + +const validUID = "c88f9680-e6bc-4b18-9a4c-eed4292c4de9" + +func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) { + etcdStorage, server := registrytest.NewEtcdStorageForResource(t, lifecycle.SchemeGroupVersion.WithResource("evictionrequests").GroupResource()) + restOptions := generic.RESTOptions{ + StorageConfig: etcdStorage, + Decorator: generic.UndecoratedStorage, + DeleteCollectionWorkers: 1, + ResourcePrefix: "evictionrequests", + } + + evictionRequestStorage, evictionRequestStatusStorage, err := NewREST(restOptions) + if err != nil { + t.Fatalf("unexpected error from REST storage: %v", err) + } + return evictionRequestStorage, evictionRequestStatusStorage, server +} + +func tester(t *testing.T, storage *REST) *genericregistrytest.Tester { + test := genericregistrytest.New(t, storage.Store) + requestInfo := &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictionrequests", + } + test.SetRequestInfo(requestInfo) + test.SetUserInfo(&user.DefaultInfo{Name: "test"}) + return test +} + +func newValidEvictionRequest() *lifecycle.EvictionRequest { + return &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + }, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + Requester: "requester-1.example.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + }, + } +} + +func TestCreate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + validEvictionRequest := newValidEvictionRequest() + validEvictionRequest.ObjectMeta = metav1.ObjectMeta{} + + invalidEvictionRequest := newValidEvictionRequest() + invalidEvictionRequest.ObjectMeta = metav1.ObjectMeta{Name: "-foo"} + test.TestCreate( + validEvictionRequest, + invalidEvictionRequest, + ) +} + +func TestUpdate(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + validEvictionRequest := newValidEvictionRequest() + test.TestUpdate( + validEvictionRequest, + func(obj runtime.Object) runtime.Object { + object := obj.(*lifecycle.EvictionRequest) + object.ObjectMeta.Annotations = map[string]string{"foo": "bar"} + return object + }, + // invalid updateFunc + func(obj runtime.Object) runtime.Object { + object := obj.(*lifecycle.EvictionRequest) + object.Spec.Target.Pod.Name = "bar" + return object + }, + ) +} + +func TestDelete(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestDelete(newValidEvictionRequest()) +} + +func TestGet(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestGet(newValidEvictionRequest()) +} + +func TestList(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestList(newValidEvictionRequest()) +} + +func TestWatch(t *testing.T) { + storage, _, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + test := tester(t, storage) + test.TestWatch( + newValidEvictionRequest(), + // matching labels + []labels.Set{}, + // not matching labels + []labels.Set{ + {"x": "y"}, + }, + // matching fields + []fields.Set{}, + // not matching fields + []fields.Set{ + {"metadata.name": "xyz"}, + {"name": "foo"}, + }, + ) +} + +func TestStatusUpdate(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + evictionRequest := newValidEvictionRequest() + evictionRequest.Status = lifecycle.EvictionRequestStatus{} + + ctx := evictionRequestContext() + key, err := storage.KeyFunc(ctx, "foo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result := &lifecycle.EvictionRequest{} + if err := storage.Storage.Create(ctx, key, evictionRequest, result, 0, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Status.ObservedGeneration != nil { + t.Errorf("we expected .status.observedGeneration to be nil but it was %v", *result.Status.ObservedGeneration) + } + evictionRequestUpdate := newValidEvictionRequest() + evictionRequestUpdate.ObjectMeta = result.ObjectMeta + evictionRequestUpdate.Labels = map[string]string{"foo": "bar"} + evictionRequestUpdate.Spec.Target.Pod.Name = "bax" + evictionRequestUpdate.Status.ObservedGeneration = new(int64(1)) + + if _, _, err := statusStorage.Update(ctx, evictionRequestUpdate.Name, rest.DefaultUpdatedObjectInfo(evictionRequestUpdate), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + obj, err := storage.Get(ctx, evictionRequestUpdate.Name, &metav1.GetOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result = obj.(*lifecycle.EvictionRequest) + if len(result.Labels) != 0 { + t.Errorf("we expected .status.labels to be empty but it was %v", result.Labels) + } + if result.Spec.Target.Pod.Name != "foo.pod" { + t.Errorf("we expected .spec.target.pod.name to not be updated but it was updated to %v", result.Spec.Target.Pod.Name) + } + if ptr.Deref(result.Status.ObservedGeneration, 0) != 1 { + t.Errorf("we expected .status.observedGeneration to be updated to but it was %v", ptr.Deref(result.Status.ObservedGeneration, -1)) + } +} +func TestGenerationNumber(t *testing.T) { + storage, statusStorage, server := newStorage(t) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + evictionRequest := newValidEvictionRequest() + evictionRequest.Generation = 100 + evictionRequest.Status.ObservedGeneration = new(int64(10)) + ctx := evictionRequestContext() + resultObj, err := storage.Create(ctx, evictionRequest, rest.ValidateAllObjectFunc, &metav1.CreateOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result, _ := resultObj.(*lifecycle.EvictionRequest) + + // Generation initialization + if result.Generation != 1 || result.Status.ObservedGeneration != nil { + t.Fatalf("Unexpected generation number %v, status generation %v", result.Generation, result.Status.ObservedGeneration) + } + + // Updates to status should not increment either spec or status generation numbers + result.Status.Conditions = append(result.Status.Conditions, metav1.Condition{Type: "Test", Status: "True", LastTransitionTime: metav1.Now(), Reason: "Reason"}) + if _, _, err := statusStorage.Update(ctx, result.Name, rest.DefaultUpdatedObjectInfo(result), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, false, &metav1.UpdateOptions{}); err != nil { + t.Errorf("unexpected error: %v", err) + } + resultObj, err = storage.Get(ctx, result.Name, &metav1.GetOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + result, _ = resultObj.(*lifecycle.EvictionRequest) + if result.Generation != 1 || result.Status.ObservedGeneration != nil { + t.Fatalf("Unexpected generation number, spec: %v, status: %v", result.Generation, result.Status.ObservedGeneration) + } +} + +func evictionRequestContext() context.Context { + ctx := genericapirequest.WithNamespace(genericapirequest.NewContext(), metav1.NamespaceDefault) + ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictionrequests", + }) + ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "test"}) + return ctx +} diff --git a/pkg/registry/lifecycle/evictionrequest/strategy.go b/pkg/registry/lifecycle/evictionrequest/strategy.go new file mode 100644 index 00000000000..558c44714cd --- /dev/null +++ b/pkg/registry/lifecycle/evictionrequest/strategy.go @@ -0,0 +1,177 @@ +/* +Copyright 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 evictionrequest + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/storage/names" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/kubernetes/pkg/apis/lifecycle/validation" +) + +// evictionRequestStrategy is the default logic that applies when creating and updating EvictionRequest objects. +type evictionRequestStrategy struct { + rest.DeclarativeValidation + names.NameGenerator +} + +func NewStrategy() *evictionRequestStrategy { + return &evictionRequestStrategy{ + rest.DeclarativeValidation{Scheme: legacyscheme.Scheme}, + names.SimpleNameGenerator, + } +} + +var _ = rest.ResetFieldsStrategy(&evictionRequestStrategy{}) + +func (*evictionRequestStrategy) NamespaceScoped() bool { + return true +} + +// GetResetFields returns the set of fields that get reset by the strategy +// and should not be modified by the user. +func (*evictionRequestStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + "lifecycle.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("status"), + ), + } + return fields +} + +// PrepareForCreate clears fields that are not allowed to be set by end users on creation. +func (*evictionRequestStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { + evictionRequest := obj.(*lifecycle.EvictionRequest) + evictionRequest.Status = lifecycle.EvictionRequestStatus{} + evictionRequest.Generation = 1 +} + +// PrepareForUpdate clears fields that are not allowed to be set by end users on update. +func (*evictionRequestStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + oldEvictionRequest := old.(*lifecycle.EvictionRequest) + newEvictionRequest := obj.(*lifecycle.EvictionRequest) + newEvictionRequest.Status = oldEvictionRequest.Status + + // Spec updates bump the generation. + if !apiequality.Semantic.DeepEqual(oldEvictionRequest.Spec, newEvictionRequest.Spec) { + newEvictionRequest.Generation = oldEvictionRequest.Generation + 1 + } +} + +// Validate validates a new EvictionRequest. +func (s *evictionRequestStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { + evictionRequest := obj.(*lifecycle.EvictionRequest) + + allErrs := validation.ValidateEvictionRequest(evictionRequest) + return allErrs +} + +func (*evictionRequestStrategy) DeclarativeValidationConfig(ctx context.Context, obj, oldObj runtime.Object) rest.DeclarativeValidationConfig { + return rest.DeclarativeValidationConfig{} +} + +func (*evictionRequestStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { + return nil +} + +func (*evictionRequestStrategy) Canonicalize(obj runtime.Object) { +} + +func (*evictionRequestStrategy) AllowCreateOnUpdate(ctx context.Context) bool { + return false +} + +// ValidateUpdate is the default update validation for an end user. +func (s *evictionRequestStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + var allErrs field.ErrorList + evictionRequest := obj.(*lifecycle.EvictionRequest) + oldEvictionRequest := old.(*lifecycle.EvictionRequest) + + allErrs = validation.ValidateEvictionRequestUpdate(evictionRequest, oldEvictionRequest) + return allErrs +} + +func (*evictionRequestStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (*evictionRequestStrategy) AllowUnconditionalUpdate(ctx context.Context) bool { + return false +} + +// evictionRequestStatusStrategy is the default logic invoked when updating object status. +type evictionRequestStatusStrategy struct { + *evictionRequestStrategy +} + +var _ = rest.ResetFieldsStrategy(&evictionRequestStatusStrategy{}) + +func NewStatusStrategy(strategy *evictionRequestStrategy) *evictionRequestStatusStrategy { + return &evictionRequestStatusStrategy{ + strategy, + } +} + +// GetResetFields returns the set of fields that get reset by the strategy +// and should not be modified by the user. +func (*evictionRequestStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + return map[fieldpath.APIVersion]*fieldpath.Set{ + "lifecycle.k8s.io/v1alpha1": fieldpath.NewSet( + fieldpath.MakePathOrDie("spec"), + fieldpath.MakePathOrDie("metadata"), + ), + } +} + +// PrepareForUpdate clears fields that are not allowed to be set by end users on update of status +func (*evictionRequestStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { + newEvictionRequest := obj.(*lifecycle.EvictionRequest) + oldEvictionRequest := old.(*lifecycle.EvictionRequest) + // Status updates should not include metadata update privileges, also, + // the evictionrequest-controller should be responsible for the labels + // and not the responders - let's not promote label updates. + metav1.ResetObjectMetaForStatus(&newEvictionRequest.ObjectMeta, &oldEvictionRequest.ObjectMeta) + newEvictionRequest.Spec = oldEvictionRequest.Spec +} + +func (*evictionRequestStatusStrategy) AllowCreateOnUpdate(ctx context.Context) bool { + return false +} + +// ValidateUpdate is the default update validation for an end user updating status +func (s *evictionRequestStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { + allErrs := validation.ValidateEvictionRequestStatusUpdate(obj.(*lifecycle.EvictionRequest), old.(*lifecycle.EvictionRequest)) + return allErrs +} + +// WarningsOnUpdate returns warnings for the given update. +func (*evictionRequestStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { + return nil +} + +func (*evictionRequestStatusStrategy) AllowUnconditionalUpdate(ctx context.Context) bool { + return false +} diff --git a/pkg/registry/lifecycle/evictionrequest/strategy_test.go b/pkg/registry/lifecycle/evictionrequest/strategy_test.go new file mode 100644 index 00000000000..44b53c792a1 --- /dev/null +++ b/pkg/registry/lifecycle/evictionrequest/strategy_test.go @@ -0,0 +1,271 @@ +/* +Copyright 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 evictionrequest + +import ( + "testing" + + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/kubernetes/pkg/apis/lifecycle" + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" +) + +const validUID = "f27eb9ee-c41a-4afd-96a1-a80b4675a8c7" + +func TestEvictionRequestStrategy_ResetFields(t *testing.T) { + strategy := NewStrategy() + for _, fields := range strategy.GetResetFields() { + if !fields.Has(fieldpath.MakePathOrDie("status")) { + t.Errorf("status should be reset on creation and update") + } + } +} + +func TestEvictionRequestStrategy(t *testing.T) { + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "create", + }) + ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "test"}) + strategy := NewStrategy() + + if !strategy.NamespaceScoped() { + t.Errorf("EvictionRequest must be namespace scoped") + } + if strategy.AllowCreateOnUpdate(ctx) { + t.Errorf("EvictionRequest should not allow create on update") + } + if len(strategy.GenerateName("test")) <= len("test") { + t.Errorf("Eviction should implement name generation") + } + if len(strategy.WarningsOnCreate(ctx, nil)) != 0 { + t.Errorf("EvictionRequest warnings on create are expected to be empty") + } + + evictionRequest := &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo"}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + Requester: "requester.domain/requester1", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + }, + } + + strategy.PrepareForCreate(ctx, evictionRequest) + if evictionRequest.Generation != int64(1) { + t.Error("EvictionRequest metadata.generation should be set to 1") + } + if evictionRequest.Status.ObservedGeneration != nil { + t.Error("EvictionRequest should not allow setting status.observedGeneration on create") + } + errs := strategy.Validate(ctx, evictionRequest) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} + +func TestEvictionRequestStrategy_Update(t *testing.T) { + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "update", + }) + ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "test"}) + strategy := NewStrategy() + + if len(strategy.WarningsOnUpdate(ctx, nil, nil)) != 0 { + t.Errorf("EvictionRequest warnings on update are expected to be empty") + } + + oldEvictionRequest := &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 1, ResourceVersion: "2", UID: "8fcabaef-2e66-459a-b6dc-c5b4c295b89d"}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + + Requester: "requester.domain/requester1", + Intent: lifecycle.EvictionRequestIntentWithdrawn, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + }, + } + + newEvictionRequest := &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", ResourceVersion: "2"}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "bar.pod", + }, + }, + Requester: "requester.domain/requester1", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(10)), + }, + } + + strategy.PrepareForUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if newEvictionRequest.Generation != int64(2) { + t.Error("EvictionRequest metadata.generation should be set to 2") + } + errs := strategy.ValidateUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if len(errs) == 0 { + t.Errorf("Expected a validation error") + } + newEvictionRequest.UID = oldEvictionRequest.UID + errs = strategy.ValidateUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} + +func TestEvictionRequestStatusStrategy_ResetFields(t *testing.T) { + strategy := NewStrategy() + statusStrategy := NewStatusStrategy(strategy) + for _, fields := range statusStrategy.GetResetFields() { + if !fields.Has(fieldpath.MakePathOrDie("spec")) { + t.Errorf("spec should be reset on status update") + } + if !fields.Has(fieldpath.MakePathOrDie("metadata")) { + t.Errorf("metadata should be reset on status update") + } + } +} + +func TestEvictionRequestStatusStrategy(t *testing.T) { + strategy := NewStatusStrategy(NewStrategy()) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: "v1alpha1", + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "update", + Subresource: "status", + }) + + oldEvictionRequest := &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 2, ResourceVersion: "2", + Annotations: map[string]string{"test": "true"}, + Labels: map[string]string{"foo": "bar"}}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + Requester: "requester.domain/requester1", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + Conditions: []metav1.Condition{ + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + }, + }, + } + + newEvictionRequest := &lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo", Generation: 2, ResourceVersion: "2", + Annotations: map[string]string{"test": "false"}, + Labels: map[string]string{"foo": "baz"}}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + + Requester: "requester.domain/requester1", + Intent: lifecycle.EvictionRequestIntentWithdrawn, + }, + Status: lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(2)), + Conditions: []metav1.Condition{ + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + { + Type: "Failed", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + }, + }, + } + + strategy.PrepareForUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if newEvictionRequest.Spec.Intent != lifecycle.EvictionRequestIntentEviction { + t.Error("EvictionRequest spec.intent should not be updated and have a non Eviction intent") + } + if newEvictionRequest.Labels["foo"] != "bar" { + t.Error("EvictionRequest should not allow changing labels") + } + if newEvictionRequest.Annotations["test"] != "true" { + t.Error("EvictionRequest should not allow changing annotations") + } + errs := strategy.ValidateUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if len(errs) == 0 { + t.Errorf("Expected a validation error") + } + newEvictionRequest.Status.Conditions = newEvictionRequest.Status.Conditions[:1] + errs = strategy.ValidateUpdate(ctx, newEvictionRequest, oldEvictionRequest) + if len(errs) != 0 { + t.Errorf("Unexpected error validating %v", errs) + } +} diff --git a/pkg/registry/lifecycle/rest/storage_coordination.go b/pkg/registry/lifecycle/rest/storage_coordination.go new file mode 100644 index 00000000000..48be1e4f3ec --- /dev/null +++ b/pkg/registry/lifecycle/rest/storage_coordination.go @@ -0,0 +1,85 @@ +/* +Copyright 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 ( + lifecyclev1alpha1 "k8s.io/api/lifecycle/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" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/klog/v2" + "k8s.io/kubernetes/pkg/api/legacyscheme" + "k8s.io/kubernetes/pkg/apis/lifecycle" + "k8s.io/kubernetes/pkg/features" + evictionstorage "k8s.io/kubernetes/pkg/registry/lifecycle/eviction/storage" + evictionrequeststorage "k8s.io/kubernetes/pkg/registry/lifecycle/evictionrequest/storage" + "k8s.io/utils/clock" +) + +type RESTStorageProvider struct { +} + +func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(lifecycle.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[lifecyclev1alpha1.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 := "evictions"; apiResourceConfigSource.ResourceEnabled(lifecyclev1alpha1.SchemeGroupVersion.WithResource(resource)) { + if utilfeature.DefaultFeatureGate.Enabled(features.EvictionRequestAPI) { + evictionStorage, evictionStatusStorage, err := evictionstorage.NewREST(restOptionsGetter, clock.RealClock{}) + if err != nil { + return storage, err + } + storage[resource] = evictionStorage + storage[resource+"/status"] = evictionStatusStorage + } else { + klog.Warning("Eviction storage is disabled because the EvictionRequestAPI feature gate is disabled") + } + } + if resource := "evictionrequests"; apiResourceConfigSource.ResourceEnabled(lifecyclev1alpha1.SchemeGroupVersion.WithResource(resource)) { + if utilfeature.DefaultFeatureGate.Enabled(features.EvictionRequestAPI) { + evictionRequestStorage, evictionRequestStatusStorage, err := evictionrequeststorage.NewREST(restOptionsGetter) + if err != nil { + return storage, err + } + storage[resource] = evictionRequestStorage + storage[resource+"/status"] = evictionRequestStatusStorage + } else { + klog.Warning("EvictionRequest storage is disabled because the EvictionRequestAPI feature gate is disabled") + } + } + + return storage, nil +} + +func (p RESTStorageProvider) GroupName() string { + return lifecycle.GroupName +} diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index 7e7a141e7e3..2021d820e5b 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -54,6 +54,7 @@ const ( coordinationGroup = "coordination.k8s.io" discoveryGroup = "discovery.k8s.io" extensionsGroup = "extensions" + lifecycleGroup = "lifecycle.k8s.io" policyGroup = "policy" rbacGroup = "rbac.authorization.k8s.io" resourceGroup = "resource.k8s.io" @@ -152,6 +153,9 @@ func viewRules() []rbacv1.PolicyRule { if utilfeature.DefaultFeatureGate.Enabled(features.CompositePodGroup) { rules = append(rules, rbacv1helpers.NewRule(Read...).Groups(schedulingGroup).Resources("compositepodgroups", "compositepodgroups/status").RuleOrDie()) } + if utilfeature.DefaultFeatureGate.Enabled(features.EvictionRequestAPI) { + rules = append(rules, rbacv1helpers.NewRule(Read...).Groups(lifecycleGroup).Resources("evictionrequests", "evictions").RuleOrDie()) + } return rules } @@ -199,6 +203,10 @@ func editRules() []rbacv1.PolicyRule { if utilfeature.DefaultFeatureGate.Enabled(features.CompositePodGroup) { rules = append(rules, rbacv1helpers.NewRule(Write...).Groups(schedulingGroup).Resources("compositepodgroups").RuleOrDie()) } + if utilfeature.DefaultFeatureGate.Enabled(features.EvictionRequestAPI) { + // "evictions" are in the domain of the system and should be only updated by the evictionrequest-controller. "evictionrequests" are user-scoped. + rules = append(rules, rbacv1helpers.NewRule(Write...).Groups(lifecycleGroup).Resources("evictionrequests").RuleOrDie()) + } return rules } diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles-featuregates.yaml b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles-featuregates.yaml index da07cac9407..9c3f029b98c 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles-featuregates.yaml +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles-featuregates.yaml @@ -281,6 +281,16 @@ items: - deletecollection - patch - update + - apiGroups: + - lifecycle.k8s.io + resources: + - evictionrequests + verbs: + - create + - delete + - deletecollection + - patch + - update - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -454,6 +464,15 @@ items: - get - list - watch + - apiGroups: + - lifecycle.k8s.io + resources: + - evictionrequests + - evictions + verbs: + - get + - list + - watch - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/staging/src/k8s.io/api/lifecycle/v1alpha1/doc.go b/staging/src/k8s.io/api/lifecycle/v1alpha1/doc.go new file mode 100644 index 00000000000..b8ccba674d6 --- /dev/null +++ b/staging/src/k8s.io/api/lifecycle/v1alpha1/doc.go @@ -0,0 +1,25 @@ +/* +Copyright 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 +// +k8s:protobuf-gen=package +// +k8s:openapi-gen=true +// +k8s:prerelease-lifecycle-gen=true +// +k8s:openapi-model-package=io.k8s.api.lifecycle.v1alpha1 + +// +groupName=lifecycle.k8s.io + +package v1alpha1 diff --git a/staging/src/k8s.io/api/lifecycle/v1alpha1/register.go b/staging/src/k8s.io/api/lifecycle/v1alpha1/register.go new file mode 100644 index 00000000000..1efd341724e --- /dev/null +++ b/staging/src/k8s.io/api/lifecycle/v1alpha1/register.go @@ -0,0 +1,55 @@ +/* +Copyright 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 = "lifecycle.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 ( + // TODO: move SchemeBuilder with zz_generated.deepcopy.go to k8s.io/api. + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +// Adds the list of known types to api.Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Eviction{}, + &EvictionList{}, + &EvictionRequest{}, + &EvictionRequestList{}, + ) + + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/staging/src/k8s.io/api/lifecycle/v1alpha1/types.go b/staging/src/k8s.io/api/lifecycle/v1alpha1/types.go new file mode 100644 index 00000000000..42bbb44cf33 --- /dev/null +++ b/staging/src/k8s.io/api/lifecycle/v1alpha1/types.go @@ -0,0 +1,680 @@ +/* +Copyright 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" + apimachinerytypes "k8s.io/apimachinery/pkg/types" +) + +const ( + // EvictionResponderImperativeEviction is a default responder that will evict pods using the imperative + // Eviction API (pods//eviction subresource) with a backoff. + EvictionResponderImperativeEviction string = "imperative-eviction.k8s.io/evictor" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.37 +// +k8s:supportsSubresource="/status" + +// EvictionRequest defines a request that should ideally result in a graceful eviction of a +// .spec.target (e.g. termination of a pod). +// +// The evictionrequest-controller observes intents of all EvictionRequests and transforms them into +// Evictions. +// - .spec.requester is set as a label on the Eviction for easier lookup. +// - Each target can have a set of responders assigned to it. Eviction objects are observed by +// these responders, who implement the eviction logic and update the Eviction's status with +// progress. +// +// There is many-to-many relationship between EvictionRequests and Evictions in general. +// And many-to-one if the target is a pod. +// +// If all requesters withdraw their eviction intent for a common target, the eviction will be +// canceled. Deleting an EvictionRequest also counts as a withdrawal. +// Once all EvictionRequest of a target are removed, the corresponding Evictions are eventually +// garbage collected. +// +// +k8s:validation-gen-nolint // Note: remove this when the API got GA +type EvictionRequest struct { + metav1.TypeMeta `json:",inline"` + + // metadata is the standard object metadata; More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata. + // +optional + // +k8s:beta(since: "1.37")=+k8s:subfield(name)=+k8s:format=k8s-long-name + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // spec defines the eviction request specification. + // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec EvictionRequestSpec `json:"spec" protobuf:"bytes,2,opt,name=spec"` + + // status represents the most recently observed status of the eviction request. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status EvictionRequestStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"` +} + +// EvictionRequestSpec is a specification of an EvictionRequest. +type EvictionRequestSpec struct { + // target contains a reference to an object (e.g. a pod) that should be evicted. + // This field is required and immutable. + // +required + // +k8s:immutable + Target EvictionRequestTarget `json:"target" protobuf:"bytes,1,opt,name=target"` + + // requester allows you to identify the entity, that requested the eviction of the target. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // Domain names *.k8s.io and *.kubernetes.io are reserved. + // This field is required and immutable. + // +required + // +k8s:required + // +k8s:immutable + // +k8s:format=k8s-prefixed-label-key + // +k8s:customValidation + Requester string `json:"requester" protobuf:"bytes,2,opt,name=requester"` + + // intent specifies the action that should be taken for the specified target. + // + // - Eviction means that the requester is interested in the eviction of the target. + // - Withdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn for a common target, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + // +required + // +k8s:required + Intent EvictionRequestIntent `json:"intent" protobuf:"bytes,3,opt,name=intent,casttype=EvictionRequestIntent"` +} + +// EvictionRequestTarget contains a reference to an object that should be evicted. +// +union +type EvictionRequestTarget struct { + // pod references a pod that is subject to eviction/termination. + // Pods that are part of a PodGroup (.spec.schedulingGroup is set) are not supported. + // +optional + // +k8s:optional + // +k8s:unionMember + Pod *EvictionRequestPodReference `json:"pod,omitempty" protobuf:"bytes,1,opt,name=pod"` +} + +// EvictionRequestPodReference contains enough information to locate the referenced pod inside the +// same namespace. +type EvictionRequestPodReference struct { + // name of the target. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-long-name + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` + // uid of the target. + // It can be found in .metadata.uid of the target and is a lowercase UUID in 8-4-4-4-12 format. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-uuid + UID apimachinerytypes.UID `json:"uid" protobuf:"bytes,2,opt,name=uid,casttype=k8s.io/apimachinery/pkg/types.UID"` +} + +// EvictionRequestIntent specifies a requester intent. +// +enum +// +k8s:enum +type EvictionRequestIntent string + +// These are intents that can be set by each requester. +const ( + // EvictionRequestIntentEviction means that the requester is interested in the eviction of the target. + EvictionRequestIntentEviction EvictionRequestIntent = "Eviction" + + // EvictionRequestIntentWithdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn for a common target, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + EvictionRequestIntentWithdrawn EvictionRequestIntent = "Withdrawn" +) + +// EvictionRequestStatus represents the last observed status of the eviction request. +type EvictionRequestStatus struct { + // conditions contain information about the eviction request. + // + // EvictionRequest specific conditions are: TargetEvicted or Failed (managed by evictionrequest-controller). + // - Failed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + // - TargetEvicted means that the target has been evicted (e.g. a pod has been terminated or deleted). + // + // These conditions can be reset if the eviction was unsuccessful and a new Eviction intent has + // been submitted. + // + // The maximum length of the conditions list is 100. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + // +k8s:optional + // +k8s:alpha(since: "1.37")=+k8s:listType=map + // +k8s:alpha(since: "1.37")=+k8s:listMapKey=type + // +k8s:maxItems=100 + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // observedGeneration is EvictionRequest's .metadata.generation observed by the evictionrequest-controller. + // The observed generation value cannot be negative and can only be incremented. + // The minimum value is 1. + // This field is managed by evictionrequest-controller. + // +optional + // +k8s:optional + // +k8s:minimum=1 + // +k8s:update=NoUnset + // +k8s:monotonic + ObservedGeneration *int64 `json:"observedGeneration,omitempty" protobuf:"varint,2,opt,name=observedGeneration"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.37 + +// EvictionRequestList contains a list of EvictionRequests resources. +type EvictionRequestList struct { + metav1.TypeMeta `json:",inline"` + // metadata is the standard list metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // items is the list of EvictionRequests. + Items []EvictionRequest `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.37 +// +k8s:supportsSubresource="/status" + +// Eviction initiates an eviction process, which should ideally result in a graceful eviction of a +// .spec.target (e.g. termination of a pod). +// +// The evictionrequest-controller observes intents of all EvictionRequests and transforms them into +// Evictions. It manages the Eviction lifecycle. +// Requesters are preserved in .status.requesters even after they have withdrawn their request. +// If all requesters withdraw their eviction intent for a common target, the eviction will be +// canceled. Once all EvictionRequest corresponding to this Eviction .spec.target have been +// removed, this Eviction object will eventually be garbage collected. +// +// If the target is a pod, the .status.targetResponders is populated from Pod's +// .spec.evictionResponders. +// +// Responders should observe and communicate through the .status to help with the eviction +// of the target when they see their state == Active in .status.targetResponders. ResponderStatus +// struct should then be periodically updated to indicate the progress or completion of the eviction +// process by each responder in .status.responders. If .status.responders[].heartbeatTime is not +// updated within the heartbeat deadline defined by the Eviction API (currently 20 minutes), the +// eviction is passed over to the next responder with a lower priority. +// +// If there are no other responders and the target is a pod, the last default +// imperative-eviction.k8s.io/evictor responder with a priority of 100 will evict the pod using the +// imperative Eviction API (pods//eviction subresource). +// +k8s:validation-gen-nolint // Note: remove this when the API got GA +type Eviction struct { + metav1.TypeMeta `json:",inline"` + + // metadata is the standard object metadata; More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata. + // .metadata.name set by the evictionrequest-controller is purely informative and subject to change. + // .spec.target field should be used to identify the target precisesly. + // + // The requester and responder names will be used as label keys and added to the labels of the + // eviction in one of the following formats: + // 1. acme.io/foo: "requester" + // 2. acme.io/foo: "responder" + // 3. acme.io/foo: "requester-responder" + // + // Please see EvictionParticipantRole for available role label values. + // +optional + // +k8s:beta(since: "1.37")=+k8s:subfield(name)=+k8s:format=k8s-long-name + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // spec defines the eviction specification. + // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec EvictionSpec `json:"spec" protobuf:"bytes,2,opt,name=spec"` + + // status represents the most recently observed status of the eviction. + // Populated by responders and evictionrequest-controller. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status EvictionStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"` +} + +// EvictionParticipantRole specifies a role of an eviction participant intent. +type EvictionParticipantRole string + +const ( + // EvictionParticipantRoleRequester identifies a requester which creates EvictionRequests. + EvictionParticipantRoleRequester EvictionParticipantRole = "requester" + + // EvictionParticipantRoleResponder identifies a responder which responds to an Eviction. + EvictionParticipantRoleResponder EvictionParticipantRole = "responder" + + // EvictionParticipantRoleRequesterResponder is both a "requester" and a "responder" at the same time. + EvictionParticipantRoleRequesterResponder EvictionParticipantRole = "requester-responder" +) + +// EvictionSpec is a specification of an Eviction. +type EvictionSpec struct { + // target contains a reference to an object (e.g. a pod) that should be evicted. + // This field is required and immutable. + // +required + // +k8s:immutable + Target EvictionTarget `json:"target" protobuf:"bytes,1,opt,name=target"` +} + +// EvictionTarget contains a reference to an object that should be evicted. +// +union +type EvictionTarget struct { + // pod references a pod that is subject to eviction/termination. + // Pods that are part of a PodGroup (.spec.schedulingGroup is set) are not supported. + // +optional + // +k8s:optional + // +k8s:unionMember + Pod *EvictionPodReference `json:"pod,omitempty" protobuf:"bytes,1,opt,name=pod"` +} + +// EvictionPodReference contains enough information to locate the referenced pod inside the same +// namespace. +type EvictionPodReference struct { + // name of the target. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-long-name + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` + // uid of the target. + // It can be found in .metadata.uid of the target and is a lowercase UUID in 8-4-4-4-12 format. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-uuid + UID apimachinerytypes.UID `json:"uid" protobuf:"bytes,2,opt,name=uid,casttype=k8s.io/apimachinery/pkg/types.UID"` +} + +// EvictionStatus represents the last observed status of the eviction request. +type EvictionStatus struct { + // conditions contain information about the eviction request. + // + // Eviction specific conditions are: TargetEvicted or Failed (managed by evictionrequest-controller). + // - Failed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + // - TargetEvicted means that the target has been evicted (e.g. a pod has been terminated or deleted). + // + // The maximum length of the conditions list is 100. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + // +k8s:optional + // +k8s:alpha(since: "1.37")=+k8s:listType=map + // +k8s:alpha(since: "1.37")=+k8s:listMapKey=type + // +k8s:maxItems=100 + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // observedGeneration is Eviction's .metadata.generation observed by the evictionrequest-controller. + // The observed generation value cannot be negative and can only be incremented. + // The minimum value is 1. + // This field is managed by evictionrequest-controller. + // +optional + // +k8s:optional + // +k8s:minimum=1 + // +k8s:monotonic + // +k8s:update=NoUnset + ObservedGeneration *int64 `json:"observedGeneration,omitempty" protobuf:"varint,2,opt,name=observedGeneration"` + + // requesters allow you to identify the entities, that requested the eviction of the target. + // If all the requesters withdraw their eviction intent, the eviction will be canceled. + // + // The maximum length of the requesters list is 100. + // If this limit is exceeded, requesters with Withdrawn intent should be dropped first. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +k8s:optional + // +k8s:listType=map + // +k8s:listMapKey=name + // +k8s:maxItems=100 + Requesters []Requester `json:"requesters,omitempty" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,3,rep,name=requesters"` + + // targetResponders reference responders that should eventually respond to this eviction + // to help with the graceful eviction of a target. These responders are selected sequentially, + // according to their specified priority by setting the Active state to the TargetResponder + // .state field. The maximum number of active responders allowed is 1. + // Eventually each responder can end up in an Interrupted, Canceled or, Completed state. + // Responders should observe these states in order to navigate their lifecycle. + // + // If the target is a pod, the field is populated from Pod's .spec.evictionResponders. Default + // responders may be added to the list according to the target. + // + // Default responders: + // - imperative-eviction.k8s.io/evictor responder with a priority of 100 is added to the list if the + // target is a pod. It will call the imperative Eviction API (pods//eviction subresource). + // This call may not succeed due to PodDisruptionBudgets, which may block the pod termination. + // It will update the responder message and try again with a backoff. + // + // The maximum length of the responders list is 11. + // The length and keys of the list cannot change once set. + // This field is managed by evictionrequest-controller. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +k8s:optional + // +k8s:listType=map + // +k8s:listMapKey=name + // +k8s:maxItems=11 + TargetResponders []TargetResponder `json:"targetResponders,omitempty" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,4,rep,name=targetResponders"` + + // responders represents the eviction process status of each declared responder. + // + // The responder list should be the same length and have the same .name fields as + // .status.targetResponders. Only responders with .name that have Active state in + // .targetResponders[].state should be updated and can be mutated. First initialization + // of the list is allowed. + // + // Each ResponderStatus is initialized by evictionrequest-controller and then managed by + // the designated responder. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +k8s:optional + // +k8s:listType=map + // +k8s:listMapKey=name + // +k8s:maxItems=11 + Responders []ResponderStatus `json:"responders,omitempty" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,5,rep,name=responders"` +} + +type EvictionConditionType string + +// These are built-in conditions of an eviction request. +const ( + // EvictionConditionFailed means that the eviction request is no longer being processed + // by any eviction responder. This can happen if the request is canceled or if no responder + // managed to evict the target (e.g. terminate or delete a pod). + EvictionConditionFailed EvictionConditionType = "Failed" + + // EvictionConditionTargetEvicted means that the target has been evicted (e.g. a pod has been + // terminated or deleted). + EvictionConditionTargetEvicted EvictionConditionType = "TargetEvicted" +) + +type EvictionConditionReason string + +// These are built-in condition reasons of an eviction request. +const ( + // EvictionConditionReasonAwaitingEviction means that this Eviction works as expected and the target + // is scheduled for an eviction. + // This reason is set for the Failed and TargetEvicted condition. + EvictionConditionReasonAwaitingEviction EvictionConditionReason = "AwaitingEviction" + // EvictionConditionReasonEvictionInvalid means that the Eviction is not accepted because the + // initial configuration is not valid. + // This reason is set for the Failed condition. + EvictionConditionReasonEvictionInvalid EvictionConditionReason = "EvictionInvalid" + // EvictionConditionReasonCanceledDueToNoRequesters means that the Eviction is canceled because there is no + // EvictionRequest with the same target and Eviction intent in .spec.intent. + // This reason is set for the Failed condition. + EvictionConditionReasonCanceledDueToNoRequesters EvictionConditionReason = "CanceledDueToNoRequesters" + // EvictionConditionReasonSucceeded means that the Eviction has successfully evicted the target. + // This reason is set for the Failed condition. + EvictionConditionReasonSucceeded EvictionConditionReason = "Succeeded" + // EvictionConditionReasonNoFurtherResponder means that the Eviction responders failed to evict + // the target and that no further responder is available. + // This reason is set for the Failed condition. + EvictionConditionReasonNoFurtherResponder EvictionConditionReason = "NoFurtherResponder" + // EvictionConditionReasonPodDeleted means that the target pod has been deleted. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonPodDeleted EvictionConditionReason = "PodDeleted" + // EvictionConditionReasonPodTerminal means that the target pod has reached a terminal state. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonPodTerminal EvictionConditionReason = "PodTerminal" + // EvictionConditionReasonEvictionFailed means that the eviction of the target was unsuccessful. + // This reason is set for the TargetEvicted condition. + EvictionConditionReasonEvictionFailed EvictionConditionReason = "EvictionFailed" +) + +// Requester allows you to identify the entity, that requested the eviction of the target. +// +structType=atomic +type Requester struct { + // name allows you to identify the entity, that requested the eviction of the target. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field must be unique for each requester. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-prefixed-label-key + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` + + // intent specifies the action that should be taken for the specified target. + // + // - Eviction means that the requester is interested in the eviction of the target. + // - Withdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + // +required + // +k8s:required + Intent RequesterIntent `json:"intent" protobuf:"bytes,2,opt,name=intent,casttype=RequesterIntent"` +} + +// RequesterIntent specifies a requester intent. +// +enum +// +k8s:enum +type RequesterIntent string + +// These are intents that can be set by each requester. +const ( + // RequesterIntentEviction means that the requester is interested in the eviction of the target. + RequesterIntentEviction RequesterIntent = "Eviction" + + // RequesterIntentWithdrawn means that the requester is no longer interested in the eviction of the target. + // If all requesters' intents are withdrawn, the eviction will be canceled. + // Cancellation consequences: + // - Inactive responders will never run. + // - Active responders are expected to cancel the eviction. + // - Completed or Interrupted responders should not take any action. + RequesterIntentWithdrawn RequesterIntent = "Withdrawn" +) + +// TargetResponder allows you to specify the responder reacting to the Eviction. +// Responders should observe and communicate through the Eviction API (see .state) to help +// with the graceful eviction of a target (e.g. termination of a pod). +// +structType=atomic +type TargetResponder struct { + // name allows you to identify the responder reacting to the Eviction. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field must be unique for each responder. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-prefixed-label-key + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` + + // priority for this responder. Higher priorities are selected first by the evictionrequest-controller. + // If there are responders with the same priority, the responder whose domain name comes first in the + // alphabetical higher domain order, will be picked. This means that the top domain labels are compared + // alphabetically first, followed by the lower domain labels. The key is compared last. + // + // The responder that is the managing controller of the pod should set the value of + // this field to 10000 to allow both for preemption or fallback registration by other + // responders. + // + // The minimum value is 0 and the maximum value is 100000. + // The interval 0-999 is reserved for responders with *.k8s.io suffix. + // This field is required and immutable. + // +required + // +k8s:required + // +k8s:minimum=0 + // +k8s:maximum=100000 + // +k8s:update=NoModify + // +k8s:update=NoUnset + Priority *int32 `json:"priority" protobuf:"varint,2,opt,name=priority"` + + // state specifies a state that is assigned by the evictionrequest-controller. Responders should observe + // this state in order to navigate their lifecycle. + // - Inactive means that the responder should not yet process this eviction request. + // - Active means that the responder is either running or expected to start soon. + // Also, startTime has been set in the ResponderStatus by the evictionrequest-controller. + // + // An active responder should currently interact with the eviction process by updating + // .status.responders, where .name is the active responder name. ResponderStatus fields + // should be periodically updated to indicate the progress or completion of the eviction process. + // If .status.responders[].heartbeatTime field is not updated within the heartbeat deadline defined + // by the Eviction API (currently 20 minutes), the eviction is passed over to the next responder + // with a lower priority. Only one responder can be active at a time. + // - Interrupted means that the responder has failed to start or failed to update + // heartbeatTime in ResponderStatus in a timely manner. + // - Canceled means that the responder has been canceled. In other words, there is no + // EvictionRequest with the same target and Eviction intent in .spec.intent. + // - Completed means that the responder has successfully completed and set completionTime + // in ResponderStatus. + // + // Please refer to the ResponderStatus in .status.responders for more details on each responder. + // +required + // +k8s:required + State ResponderStateType `json:"state" protobuf:"bytes,3,opt,name=state,casttype=ResponderStateType"` +} + +// ResponderStateType specifies a state that is assigned by the evictionrequest-controller. +// +enum +// +k8s:enum +type ResponderStateType string + +const ( + // ResponderStateInactive means that the responder should not yet process this eviction request. + ResponderStateInactive ResponderStateType = "Inactive" + + // ResponderStateActive means that the responder is either running or expected to start soon. + // Also, startTime has been set in the ResponderStatus by the evictionrequest-controller. + // + // An active responder should currently interact with the eviction process by updating + // .status.responders, where .name is the active responder name. ResponderStatus fields + // should be periodically updated to indicate the progress or completion of the eviction process. + // If .status.responders[].heartbeatTime field is not updated within the heartbeat deadline defined + // by the Eviction API (currently 20 minutes), the eviction is passed over to the next responder + // with a lower priority. Only one responder can be active at a time. + ResponderStateActive ResponderStateType = "Active" + + // ResponderStateInterrupted means that the responder has failed to start or failed to update + // heartbeatTime in ResponderStatus in a timely manner. + ResponderStateInterrupted ResponderStateType = "Interrupted" + + // ResponderStateCanceled means that the responder has been canceled. In other words, there + // is no EvictionRequest with the same target and Eviction intent in .spec.intent. + ResponderStateCanceled ResponderStateType = "Canceled" + + // ResponderStateCompleted means that the responder has successfully completed and set completionTime + // in ResponderStatus. + ResponderStateCompleted ResponderStateType = "Completed" +) + +// ResponderStatus represents the last observed status of the eviction process of the responder. +// It should be only updated by the designated responder whose name is .name field. +// +structType=granular +type ResponderStatus struct { + // name allows you to identify the responder reacting to the Eviction. + // + // It must be a valid domain-prefixed key (such as "acme.io/foo"). + // This field is initialized by Kubernetes and must be unique for each responder. + // This field is required. + // +required + // +k8s:required + // +k8s:format=k8s-prefixed-label-key + Name string `json:"name" protobuf:"bytes,1,opt,name=name"` + + // startTime tracks the time at which this responder was designated as active and should start + // processing the eviction request. + // It should reflect the present time when set. + // This field is initialized by Kubernetes when this responder becomes active. + // This field becomes immutable once set. + // +optional + // +k8s:optional + // +k8s:update=NoModify + // +k8s:update=NoUnset + StartTime *metav1.Time `json:"startTime,omitempty" protobuf:"bytes,2,opt,name=startTime"` + + // heartbeatTime is the last time at which the eviction process was reported to be in progress + // by the responder. + // It should reflect the present time when set. + // Responders should avoid heartbeats more frequent than 20 seconds to avoid overloading the + // control-plane. + // +optional + // +k8s:optional + HeartbeatTime *metav1.Time `json:"heartbeatTime,omitempty" protobuf:"bytes,3,opt,name=heartbeatTime"` + + // expectedCompletionTime is the time at which the eviction process step is expected to end for the + // responder. + // The time cannot be set to the past. + // May be omitted if no estimate can be made. + // +optional + // +k8s:optional + ExpectedCompletionTime *metav1.Time `json:"expectedCompletionTime,omitempty" protobuf:"bytes,4,opt,name=expectedCompletionTime"` + + // completionTime tracks the time at which the Responder stopped processing the eviction request. + // Completion means that the responders has either fully or partially completed the + // eviction process, which may have resulted in target eviction (e.g. pod termination). + // It should reflect the present time when set. + // This field becomes immutable once set. + // +optional + // +k8s:optional + // +k8s:update=NoModify + // +k8s:update=NoUnset + CompletionTime *metav1.Time `json:"completionTime,omitempty" protobuf:"bytes,5,opt,name=completionTime"` + + // message provides human-readable details about the state of the responder and the eviction + // process. + // Maximum length is 4000 characters. + // +optional + // +k8s:optional + // +k8s:maxLength=4000 + Message *string `json:"message,omitempty" protobuf:"bytes,6,opt,name=message"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:prerelease-lifecycle-gen:introduced=1.37 + +// EvictionList contains a list of Eviction resources. +type EvictionList struct { + metav1.TypeMeta `json:",inline"` + // metadata is the standard list metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // items is the list of Evictions. + Items []Eviction `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 2cfc1217be2..70ad2a9b796 100644 --- a/staging/src/k8s.io/api/roundtrip_test.go +++ b/staging/src/k8s.io/api/roundtrip_test.go @@ -56,6 +56,7 @@ import ( flowcontrolv1beta2 "k8s.io/api/flowcontrol/v1beta2" flowcontrolv1beta3 "k8s.io/api/flowcontrol/v1beta3" imagepolicyv1alpha1 "k8s.io/api/imagepolicy/v1alpha1" + lifecyclev1alpha1 "k8s.io/api/lifecycle/v1alpha1" networkingv1 "k8s.io/api/networking/v1" networkingv1beta1 "k8s.io/api/networking/v1beta1" nodev1 "k8s.io/api/node/v1" @@ -123,6 +124,7 @@ var groups = []runtime.SchemeBuilder{ flowcontrolv1beta3.SchemeBuilder, flowcontrolv1.SchemeBuilder, imagepolicyv1alpha1.SchemeBuilder, + lifecyclev1alpha1.SchemeBuilder, networkingv1.SchemeBuilder, networkingv1beta1.SchemeBuilder, nodev1.SchemeBuilder, diff --git a/staging/src/k8s.io/client-go/kubernetes/fake/register.go b/staging/src/k8s.io/client-go/kubernetes/fake/register.go index 111f0c9d55b..4208f861baa 100644 --- a/staging/src/k8s.io/client-go/kubernetes/fake/register.go +++ b/staging/src/k8s.io/client-go/kubernetes/fake/register.go @@ -39,6 +39,7 @@ import ( certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" certificatesv1beta1 "k8s.io/api/certificates/v1beta1" coordinationv1 "k8s.io/api/coordination/v1" + coordinationv1alpha1 "k8s.io/api/coordination/v1alpha1" coordinationv1alpha2 "k8s.io/api/coordination/v1alpha2" coordinationv1beta1 "k8s.io/api/coordination/v1beta1" corev1 "k8s.io/api/core/v1" @@ -103,6 +104,7 @@ var localSchemeBuilder = runtime.SchemeBuilder{ certificatesv1.AddToScheme, certificatesv1beta1.AddToScheme, certificatesv1alpha1.AddToScheme, + coordinationv1alpha1.AddToScheme, coordinationv1alpha2.AddToScheme, coordinationv1beta1.AddToScheme, coordinationv1.AddToScheme, diff --git a/staging/src/k8s.io/client-go/kubernetes/scheme/register.go b/staging/src/k8s.io/client-go/kubernetes/scheme/register.go index 2dd01c99fa8..213e4e51070 100644 --- a/staging/src/k8s.io/client-go/kubernetes/scheme/register.go +++ b/staging/src/k8s.io/client-go/kubernetes/scheme/register.go @@ -39,6 +39,7 @@ import ( certificatesv1alpha1 "k8s.io/api/certificates/v1alpha1" certificatesv1beta1 "k8s.io/api/certificates/v1beta1" coordinationv1 "k8s.io/api/coordination/v1" + coordinationv1alpha1 "k8s.io/api/coordination/v1alpha1" coordinationv1alpha2 "k8s.io/api/coordination/v1alpha2" coordinationv1beta1 "k8s.io/api/coordination/v1beta1" corev1 "k8s.io/api/core/v1" @@ -103,6 +104,7 @@ var localSchemeBuilder = runtime.SchemeBuilder{ certificatesv1.AddToScheme, certificatesv1beta1.AddToScheme, certificatesv1alpha1.AddToScheme, + coordinationv1alpha1.AddToScheme, coordinationv1alpha2.AddToScheme, coordinationv1beta1.AddToScheme, coordinationv1.AddToScheme, diff --git a/test/declarative_validation/core/pod/eviction_responders.go b/test/declarative_validation/core/pod/eviction_responders.go index 51d4d605439..8b76a9a7b2c 100644 --- a/test/declarative_validation/core/pod/eviction_responders.go +++ b/test/declarative_validation/core/pod/eviction_responders.go @@ -1,3 +1,19 @@ +/* +Copyright 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 pod import ( diff --git a/test/declarative_validation/lifecycle/eviction/declarative_validation_test.go b/test/declarative_validation/lifecycle/eviction/declarative_validation_test.go new file mode 100644 index 00000000000..c5a28229040 --- /dev/null +++ b/test/declarative_validation/lifecycle/eviction/declarative_validation_test.go @@ -0,0 +1,653 @@ +/* +Copyright 2025 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 eviction + +import ( + "fmt" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachinerytypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + apitesting "k8s.io/kubernetes/pkg/api/testing" + "k8s.io/kubernetes/pkg/apis/lifecycle" + registry "k8s.io/kubernetes/pkg/registry/lifecycle/eviction" + "k8s.io/kubernetes/test/declarative_validation/meta" + utilsclock "k8s.io/utils/clock" + testing2 "k8s.io/utils/clock/testing" + + // Ensure all API groups are registered with the scheme + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" +) + +const validUID = "a2ee91f4-e13c-44db-9edc-4240e7383ab9" + +func TestDeclarativeValidate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // Eviction is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidate(t, apiVersion) + }) + } +} + +func testDeclarativeValidate(t *testing.T, apiVersion string) { + testCases := map[string]struct { + input *lifecycle.Eviction + errors field.ErrorList + }{ + "valid": { + input: mkValidEviction(), + }, + "name is not valid": { + input: mkValidEviction(setName("-invalid-name")), + errors: []*field.Error{ + field.Invalid(field.NewPath("metadata", "name"), "", "").WithOrigin("format=k8s-long-name").MarkBeta(), + }, + }, + "missing target": { + input: mkValidEviction(clearTarget()), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("union"), + }, + }, + "missing target name": { + input: mkValidEviction(setTarget("", validUID)), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "target", "pod", "name"), ""), + }, + }, + "invalid target name": { + input: mkValidEviction(setTarget("_test", validUID)), + errors: []*field.Error{ + field.Invalid(field.NewPath("spec", "target", "pod", "name"), "", "").WithOrigin("format=k8s-long-name"), + }, + }, + "missing target uid": { + input: mkValidEviction(setTarget("bar", "")), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "target", "pod", "uid"), ""), + }, + }, + "invalid target uid": { + input: mkValidEviction(setTarget("bar", "invalid-uid")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target", "pod", "uid"), "", "").WithOrigin("format=k8s-uuid"), + }, + }, + } + clock := testing2.NewFakePassiveClock(time.Now()) + + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictions", + IsResourceRequest: true, + Verb: "create", + }) + strategy := registry.NewStrategy(clock) + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + apitesting.VerifyValidationEquivalence(t, ctx, tc.input, strategy, tc.errors) + }) + } + + obj := mkValidEviction() + meta.RunObjectMetaTestCases(t, ctx, obj, strategy, meta.WithStringentFinalizerValidation()) +} + +func TestDeclarativeValidateUpdate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // Eviction is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidateUpdate(t, apiVersion) + }) + } +} + +func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { + testCases := map[string]struct { + input *lifecycle.Eviction + oldInput *lifecycle.Eviction + errors field.ErrorList + }{ + "valid": { + oldInput: mkValidEviction(), + input: mkValidEviction(func(obj *lifecycle.Eviction) { + obj.ObjectMeta.Labels = map[string]string{"foo": "bar"} + }), + }, + "clear target": { + oldInput: mkValidEviction(), + input: mkValidEviction(clearTarget()), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + "change target name": { + oldInput: mkValidEviction(), + input: mkValidEviction(setTarget("change", validUID)), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + "change target uid": { + oldInput: mkValidEviction(), + input: mkValidEviction(setTarget("bar", "")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + } + clock := testing2.NewFakePassiveClock(time.Now()) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictions", + IsResourceRequest: true, + Verb: "update", + }) + strategy := registry.NewStrategy(clock) + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + tc.oldInput.ResourceVersion = "0" + tc.input.ResourceVersion = "1" + apitesting.VerifyUpdateValidationEquivalence(t, ctx, tc.input, tc.oldInput, strategy, tc.errors) + }) + } + + obj := mkValidEviction() + meta.RunObjectMetaUpdateTestCases(t, ctx, obj, strategy, meta.WithStringentFinalizerValidation()) +} + +func TestDeclarativeValidateStatusUpdate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // Eviction is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidateStatusUpdate(t, apiVersion) + }) + } +} + +func testDeclarativeValidateStatusUpdate(t *testing.T, apiVersion string) { + clock := testing2.NewFakePassiveClock(time.Now()) + clockAfter := func(duration time.Duration) utilsclock.PassiveClock { + return testing2.NewFakePassiveClock(clock.Now().Add(duration)) + } + + testCases := map[string]struct { + input *lifecycle.EvictionStatus + oldInput *lifecycle.EvictionStatus + errors field.ErrorList + }{ + "valid": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, setObservedGeneration(new(int64(5)))), + }, + // conditions + "too many conditions": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addConditionsCount(clock, 101)), + errors: []*field.Error{ + field.TooMany(field.NewPath("status", "conditions"), 101, 100).WithOrigin("maxItems"), + }, + }, + // observedGeneration + "clear generation": { + oldInput: mkValidEvictionStatus(0, setObservedGeneration(new(int64(1)))), + input: mkValidEvictionStatus(0, setObservedGeneration(nil)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), nil, "").WithOrigin("update"), + }, + }, + "set generation to 0": { + oldInput: mkValidEvictionStatus(0, setObservedGeneration(nil)), + input: mkValidEvictionStatus(0, setObservedGeneration(new(int64(0)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), 0, "").WithOrigin("minimum"), + }, + }, + "set generation to negative": { + oldInput: mkValidEvictionStatus(0, setObservedGeneration(nil)), + input: mkValidEvictionStatus(0, setObservedGeneration(new(int64(-1)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), -1, "").WithOrigin("minimum"), + }, + }, + "decrease generation": { + oldInput: mkValidEvictionStatus(0, setObservedGeneration(new(int64(2)))), + input: mkValidEvictionStatus(0, setObservedGeneration(new(int64(1)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), 1, "").WithOrigin("monotonic"), + }, + }, + // requesters + "too many requesters": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequestersCount(lifecycle.RequesterIntentEviction, 40), + addRequestersCount(lifecycle.RequesterIntentWithdrawn, 40), + addRequestersCount(lifecycle.RequesterIntentEviction, 21)), + errors: []*field.Error{ + field.TooMany(field.NewPath("status", "requesters"), 101, 100).WithOrigin("maxItems"), + }, + }, + "add a duplicate requesters": { + oldInput: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz", "foo.example.com/baz")), + errors: field.ErrorList{ + field.Duplicate(field.NewPath("status", "requesters").Index(1), ""), + }, + }, + "add a requester without a name": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "")), + errors: field.ErrorList{ + field.Required(field.NewPath("status", "requesters").Index(0).Child("name"), ""), + }, + }, + "add a requester with invalid name": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("status", "requesters").Index(0).Child("name"), "", "").WithOrigin("format=k8s-prefixed-label-key"), + }, + }, + "add a requester without an intent": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequesters("", "foo.example.com/baz")), + errors: field.ErrorList{ + field.Required(field.NewPath("status", "requesters").Index(0).Child("intent"), ""), + }, + }, + "add a requester with invalid intent": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addRequesters("Invalid", "foo.example.com/bar")), + errors: field.ErrorList{ + field.NotSupported(field.NewPath("status", "requesters").Index(0).Child("intent"), "", []lifecycle.RequesterIntent{lifecycle.RequesterIntentEviction, lifecycle.RequesterIntentWithdrawn}), + }, + }, + "change a valid requester to an invalid one": { + oldInput: mkValidEvictionStatus(0, addRequesters(lifecycle.RequesterIntentEviction, "foo.example.com/baz")), + input: mkValidEvictionStatus(0, addRequesters("Invalid", "foo.example.com/baz")), + errors: field.ErrorList{ + field.NotSupported(field.NewPath("status", "requesters").Index(0).Child("intent"), "", []lifecycle.RequesterIntent{lifecycle.RequesterIntentEviction, lifecycle.RequesterIntentWithdrawn}), + }, + }, + // targetResponders and responders + "duplicate targetResponders and responders": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, addTargetResponders("example.com/baz", "example.com/baz"), addStatusResponders("example.com/baz", "example.com/baz")), + errors: []*field.Error{ + field.Duplicate(field.NewPath("status", "targetResponders").Index(1), ""), + field.Duplicate(field.NewPath("status", "responders").Index(1), ""), + }, + }, + "required targetResponder and responder name": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetResponders(""), + setStateFor(lifecycle.ResponderStateActive, 0), + addStatusResponders(""), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Required(field.NewPath("status", "targetResponders").Index(0).Child("name"), ""), + field.Required(field.NewPath("status", "responders").Index(0).Child("name"), ""), + }, + }, + "invalid targetResponder and responder name": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(0, + addTargetResponders("foo"), + setStateFor(lifecycle.ResponderStateActive, 0), + addStatusResponders("foo"), + setRespondersStartTime(clock, 0, 1), + ), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("name"), "", "").WithOrigin("format=k8s-prefixed-label-key"), + field.Invalid(field.NewPath("status", "responders").Index(0).Child("name"), "", "").WithOrigin("format=k8s-prefixed-label-key"), + }, + }, + // targetResponders + "too many targetResponders": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatusWithStatuses(12, 0), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders"), "", "must be the same length as status.targetResponders and contain the same keys in the same order").MarkFromImperative(), + field.TooMany(field.NewPath("status", "targetResponders"), 12, 11).WithOrigin("maxItems"), + }, + }, + "required targetResponder priority": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setPriorityFor(nil, 0)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "targetResponders").Index(0).Child("priority"), ""), + }, + }, + "negative targetResponder priority": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setPriorityFor(new(int32(-1)), 0)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "").WithOrigin("minimum"), + }, + }, + "too high targetResponder priority": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setPriorityFor(new(int32(100001)), 0)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "").WithOrigin("maximum"), + }, + }, + "cannot change targetResponder priority": { + oldInput: mkValidEvictionStatus(0, + addTargetResponders(responderName(0)), + setStateFor(lifecycle.ResponderStateActive, 0), + addStatusResponders(responderName(0)), + setRespondersStartTime(clock, 0, 1), + setPriorityFor(new(int32(3000)), 0)), + input: mkValidEvictionStatus(0, + addTargetResponders(responderName(0)), + setStateFor(lifecycle.ResponderStateActive, 0), + addStatusResponders(responderName(0)), + setRespondersStartTime(clock, 0, 1), + setPriorityFor(new(int32(3005)), 0)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("priority"), "", "").WithOrigin("update"), + }, + }, + "required targetResponder state": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setStateFor("", 0)), + errors: []*field.Error{ + field.Required(field.NewPath("status", "targetResponders").Index(0).Child("state"), ""), + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Canceled, Completed, Interrupted").MarkFromImperative(), + }, + }, + "invalid targetResponder state": { + oldInput: mkValidEvictionStatus(0), + input: mkValidEvictionStatus(1, setStateFor("Invalid", 0)), + errors: []*field.Error{ + field.NotSupported(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", []string(nil)), + field.Invalid(field.NewPath("status", "targetResponders").Index(0).Child("state"), "", "must be one of: Canceled, Completed, Interrupted").MarkFromImperative(), + }, + }, + // responders + "too many status responders": { + oldInput: mkValidEvictionStatusWithStatuses(18, 17), + input: mkValidEvictionStatus(18), + errors: []*field.Error{ + field.TooMany(field.NewPath("status", "responders"), 18, 17).WithOrigin("maxItems"), + }, + }, + // status responder name + // startTime + "startTime cannot be removed once set": { + oldInput: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("startTime"), nil, "").WithOrigin("update"), + }, + }, + "startTime cannot be changed once set": { + oldInput: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setRespondersStartTime(clockAfter(15*time.Second), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("startTime"), nil, "").WithOrigin("update"), + }, + }, + // heartbeatTime + // expectedCompletionTime + // completionTime + "completionTime cannot be changed once set": { + oldInput: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockAfter(5*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockAfter(4*time.Minute), 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), nil, "").WithOrigin("update"), + }, + }, + "completionTime cannot be removed once set": { + oldInput: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1), + setRespondersCompletionTime(clockAfter(5*time.Minute), 0, 1)), + input: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "responders").Index(0).Child("completionTime"), nil, "").WithOrigin("update"), + }, + }, + // message + "too long message": { + oldInput: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clock, 0, 1)), + input: mkValidEvictionStatus(2, + setRespondersStartTime(clock, 0, 1), + setRespondersHeartBeatTime(clock, 0, 1), + setRespondersMessage(0, 1, strings.Repeat("a", 4000))), + errors: []*field.Error{ + field.TooLongCharacters(field.NewPath("status", "responders").Index(0).Child("message"), "", 4000).WithOrigin("maxLength"), + }, + }, + } + strategy := registry.NewStatusStrategy(registry.NewStrategy(clock)) + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictions", + IsResourceRequest: true, + Verb: "update", + Subresource: "status", + }) + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + oldEviction := mkValidEviction() + oldEviction.ResourceVersion = "0" + oldEviction.Status = *tc.oldInput + eviction := mkValidEviction() + eviction.ResourceVersion = "1" + eviction.Status = *tc.input + apitesting.VerifyUpdateValidationEquivalence(t, ctx, eviction, oldEviction, strategy, tc.errors, apitesting.WithSubResources("status")) + }) + } + + meta.RunConditionTestCases(t, ctx, field.NewPath("status", "conditions"), &lifecycle.Eviction{}, strategy, func(obj *lifecycle.Eviction, c []metav1.Condition) { + *obj = *mkValidEviction(func(r *lifecycle.Eviction) { r.Status.Conditions = c }) + }) +} + +func mkValidEviction(tweaks ...func(obj *lifecycle.Eviction)) *lifecycle.Eviction { + obj := lifecycle.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: "evict-pod-1-foo.pod", Namespace: "foo"}, + Spec: lifecycle.EvictionSpec{ + Target: lifecycle.EvictionTarget{ + Pod: &lifecycle.EvictionPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + }, + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} + +func setName(name string) func(obj *lifecycle.Eviction) { + return func(obj *lifecycle.Eviction) { + obj.Name = name + } +} +func clearTarget() func(obj *lifecycle.Eviction) { + return func(obj *lifecycle.Eviction) { + obj.Spec.Target.Pod = nil + } +} +func setTarget(name, uid string) func(obj *lifecycle.Eviction) { + return func(obj *lifecycle.Eviction) { + obj.Spec.Target.Pod = &lifecycle.EvictionPodReference{ + UID: apimachinerytypes.UID(uid), + Name: name, + } + } +} + +func responderName(i int) string { + return fmt.Sprintf("responder.example.com/bar%d", i) +} +func mkValidEvictionStatus(responders int, tweaks ...func(obj *lifecycle.EvictionStatus)) *lifecycle.EvictionStatus { + return mkValidEvictionStatusWithStatuses(responders, responders, tweaks...) +} +func mkValidEvictionStatusWithStatuses(responders, statuses int, tweaks ...func(obj *lifecycle.EvictionStatus)) *lifecycle.EvictionStatus { + obj := lifecycle.EvictionStatus{ + ObservedGeneration: new(int64(1)), + } + for i := range responders { + obj.TargetResponders = append(obj.TargetResponders, lifecycle.TargetResponder{ + Name: responderName(i), + Priority: new(5000 - int32(i)), + State: lifecycle.ResponderStateInactive, + }) + if i == 0 { + obj.TargetResponders[i].State = lifecycle.ResponderStateActive + } + } + for i := range statuses { + obj.Responders = append(obj.Responders, lifecycle.ResponderStatus{ + Name: responderName(i), + }) + if i == 0 { + obj.Responders[i].StartTime = new(metav1.Now()) + } + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} +func addCondition(clock utilsclock.PassiveClock, name lifecycle.EvictionConditionType, status metav1.ConditionStatus) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + newCond := metav1.Condition{ + Type: string(name), + Status: status, + Reason: string(name) + "Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + } + obj.Conditions = append(obj.Conditions, newCond) + } +} +func addConditionsCount(clock utilsclock.PassiveClock, count int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := range count { + addCondition(clock, lifecycle.EvictionConditionType(fmt.Sprintf("Condition%d", i)), metav1.ConditionTrue)(obj) + } + } +} + +func setObservedGeneration(generation *int64) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.ObservedGeneration = generation + } +} +func addRequesters(intent lifecycle.RequesterIntent, names ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for _, name := range names { + obj.Requesters = append(obj.Requesters, lifecycle.Requester{Name: name, Intent: intent}) + } + } +} +func addRequestersCount(intent lifecycle.RequesterIntent, count int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := range count { + obj.Requesters = append(obj.Requesters, lifecycle.Requester{Name: fmt.Sprintf("foo.example.com/bar-%d", i), Intent: intent}) + } + } +} +func addTargetResponders(responders ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + lastPriority := len(obj.Responders) + for i, name := range responders { + obj.TargetResponders = append(obj.TargetResponders, lifecycle.TargetResponder{ + Name: name, + Priority: new(5000 - int32(lastPriority+i)), + State: lifecycle.ResponderStateInactive, + }) + } + } +} +func setStateFor(state lifecycle.ResponderStateType, idx int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.TargetResponders[idx].State = state + } +} +func setPriorityFor(priority *int32, idx int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + obj.TargetResponders[idx].Priority = priority + } +} +func addStatusResponders(responders ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for _, responder := range responders { + obj.Responders = append(obj.Responders, lifecycle.ResponderStatus{Name: responder}) + } + } +} +func setRespondersStartTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].StartTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersHeartBeatTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].HeartbeatTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersCompletionTime(clock utilsclock.PassiveClock, from, to int) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + obj.Responders[i].CompletionTime = &metav1.Time{Time: clock.Now().Add(time.Duration(i) * time.Second)} + } + } +} +func setRespondersMessage(from, to int, suffixes ...string) func(obj *lifecycle.EvictionStatus) { + return func(obj *lifecycle.EvictionStatus) { + for i := from; i < to; i++ { + msg := fmt.Sprintf("message %d", i) + for _, suffix := range suffixes { + msg += suffix + } + obj.Responders[i].Message = new(msg) + } + } +} diff --git a/test/declarative_validation/lifecycle/evictionrequest/declarative_validation_test.go b/test/declarative_validation/lifecycle/evictionrequest/declarative_validation_test.go new file mode 100644 index 00000000000..1f2d6280396 --- /dev/null +++ b/test/declarative_validation/lifecycle/evictionrequest/declarative_validation_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2025 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 evictionrequest + +import ( + "fmt" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachinerytypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + apitesting "k8s.io/kubernetes/pkg/api/testing" + "k8s.io/kubernetes/pkg/apis/lifecycle" + registry "k8s.io/kubernetes/pkg/registry/lifecycle/evictionrequest" + "k8s.io/kubernetes/test/declarative_validation/meta" + utilsclock "k8s.io/utils/clock" + testing2 "k8s.io/utils/clock/testing" + + // Ensure all API groups are registered with the scheme + _ "k8s.io/kubernetes/pkg/apis/lifecycle/install" +) + +const validUID = "5477c2ff-f59f-4eb9-a0be-e54232323faa" + +func TestDeclarativeValidate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // EvictionRequest is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidate(t, apiVersion) + }) + } +} + +func testDeclarativeValidate(t *testing.T, apiVersion string) { + testCases := map[string]struct { + input *lifecycle.EvictionRequest + errors field.ErrorList + }{ + "valid": { + input: mkValidEvictionRequest(), + }, + "name is not valid": { + input: mkValidEvictionRequest(setName("-invalid-name")), + errors: []*field.Error{ + field.Invalid(field.NewPath("metadata", "name"), "", "").WithOrigin("format=k8s-long-name").MarkBeta(), + }, + }, + "missing target": { + input: mkValidEvictionRequest(clearTarget()), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("union"), + }, + }, + "missing target name": { + input: mkValidEvictionRequest(setTarget("", validUID)), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "target", "pod", "name"), ""), + }, + }, + "invalid target name": { + input: mkValidEvictionRequest(setTarget("_test", validUID)), + errors: []*field.Error{ + field.Invalid(field.NewPath("spec", "target", "pod", "name"), "", "").WithOrigin("format=k8s-long-name"), + }, + }, + "missing target uid": { + input: mkValidEvictionRequest(setTarget("bar", "")), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "target", "pod", "uid"), ""), + }, + }, + "invalid target uid": { + input: mkValidEvictionRequest(setTarget("bar", "invalid-uid")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target", "pod", "uid"), "", "").WithOrigin("format=k8s-uuid"), + }, + }, + "missing requester name": { + input: mkValidEvictionRequest(setRequester("")), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "requester"), ""), + }, + }, + "invalid requester name": { + input: mkValidEvictionRequest(setRequester("foo")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "requester"), "", "").WithOrigin("format=k8s-prefixed-label-key"), + }, + }, + "invalid requester name - reserved k8s.io domain": { + input: mkValidEvictionRequest(setRequester("k8s.io/key")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "requester"), "", "domain names *.k8s.io, *.kubernetes.io are reserved"), + }, + }, + "invalid requester name - reserved kubernetes.io domain": { + input: mkValidEvictionRequest(setRequester("dev.kubernetes.io/key")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "requester"), "", "domain names *.k8s.io, *.kubernetes.io are reserved"), + }, + }, + "requester without an intent": { + input: mkValidEvictionRequest(setRequesterIntent("")), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "intent"), ""), + }, + }, + "requester with invalid intent": { + input: mkValidEvictionRequest(setRequesterIntent("Invalid")), + errors: field.ErrorList{ + field.NotSupported(field.NewPath("spec", "intent"), "", []lifecycle.RequesterIntent{lifecycle.RequesterIntentEviction, lifecycle.RequesterIntentWithdrawn}), + }, + }, + } + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "create", + }) + strategy := registry.NewStrategy() + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + apitesting.VerifyValidationEquivalence(t, ctx, tc.input, strategy, tc.errors) + }) + } + + obj := mkValidEvictionRequest() + meta.RunObjectMetaTestCases(t, ctx, obj, strategy, meta.WithStringentFinalizerValidation()) +} + +func TestDeclarativeValidateUpdate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // EvictionRequest is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidateUpdate(t, apiVersion) + }) + } +} + +func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) { + testCases := map[string]struct { + input *lifecycle.EvictionRequest + oldInput *lifecycle.EvictionRequest + errors field.ErrorList + }{ + "valid": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setRequesterIntent(lifecycle.EvictionRequestIntentWithdrawn)), + }, + "clear target": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(clearTarget()), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + "change target name": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setTarget("change", validUID)), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + "change target uid": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setTarget("bar", "")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "target"), "", "").WithOrigin("immutable"), + }, + }, + "change requester name": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setRequester("foo.example.com/baz")), + errors: field.ErrorList{ + field.Invalid(field.NewPath("spec", "requester"), "", "").WithOrigin("immutable"), + }, + }, + "change to a requester without an intent": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setRequesterIntent("")), + errors: field.ErrorList{ + field.Required(field.NewPath("spec", "intent"), ""), + }, + }, + "change to a requester with invalid intent": { + oldInput: mkValidEvictionRequest(), + input: mkValidEvictionRequest(setRequesterIntent("Invalid")), + errors: field.ErrorList{ + field.NotSupported(field.NewPath("spec", "intent"), "", []lifecycle.RequesterIntent{lifecycle.RequesterIntentEviction, lifecycle.RequesterIntentWithdrawn}), + }, + }, + } + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "update", + }) + strategy := registry.NewStrategy() + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + tc.oldInput.ResourceVersion = "0" + tc.input.ResourceVersion = "1" + apitesting.VerifyUpdateValidationEquivalence(t, ctx, tc.input, tc.oldInput, strategy, tc.errors) + }) + } + + obj := mkValidEvictionRequest() + meta.RunObjectMetaUpdateTestCases(t, ctx, obj, strategy, meta.WithStringentFinalizerValidation()) +} + +func TestDeclarativeValidateStatusUpdate(t *testing.T) { + apiVersions := []string{"v1alpha1"} // EvictionRequest is currently only in v1alpha1 + for _, apiVersion := range apiVersions { + t.Run(apiVersion, func(t *testing.T) { + testDeclarativeValidateStatusUpdate(t, apiVersion) + }) + } +} + +func testDeclarativeValidateStatusUpdate(t *testing.T, apiVersion string) { + clock := testing2.NewFakePassiveClock(time.Now()) + + testCases := map[string]struct { + input *lifecycle.EvictionRequestStatus + oldInput *lifecycle.EvictionRequestStatus + errors field.ErrorList + }{ + "valid": { + oldInput: mkValidEvictionRequestStatus(0), + input: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(5)))), + }, + // conditions + "too many conditions": { + oldInput: mkValidEvictionRequestStatus(0), + input: mkValidEvictionRequestStatus(0, addConditionsCount(clock, 101)), + errors: []*field.Error{ + field.TooMany(field.NewPath("status", "conditions"), 101, 100).WithOrigin("maxItems"), + }, + }, + // observedGeneration + "clear generation": { + oldInput: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(1)))), + input: mkValidEvictionRequestStatus(0, setObservedGeneration(nil)), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), nil, "").WithOrigin("update"), + }, + }, + "set generation to 0": { + oldInput: mkValidEvictionRequestStatus(0, setObservedGeneration(nil)), + input: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(0)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), 0, "").WithOrigin("minimum"), + }, + }, + "set generation to negative": { + oldInput: mkValidEvictionRequestStatus(0, setObservedGeneration(nil)), + input: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(-1)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), -1, "").WithOrigin("minimum"), + }, + }, + "decrease generation": { + oldInput: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(2)))), + input: mkValidEvictionRequestStatus(0, setObservedGeneration(new(int64(1)))), + errors: []*field.Error{ + field.Invalid(field.NewPath("status", "observedGeneration"), 1, "").WithOrigin("monotonic"), + }, + }, + } + strategy := registry.NewStatusStrategy(registry.NewStrategy()) + + ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{ + APIGroup: "lifecycle.k8s.io", + APIVersion: apiVersion, + Resource: "evictionrequests", + IsResourceRequest: true, + Verb: "update", + Subresource: "status", + }) + + for k, tc := range testCases { + t.Run(k, func(t *testing.T) { + oldEvictionRequest := mkValidEvictionRequest() + oldEvictionRequest.ResourceVersion = "0" + oldEvictionRequest.Status = *tc.oldInput + evictionRequest := mkValidEvictionRequest() + evictionRequest.ResourceVersion = "1" + evictionRequest.Status = *tc.input + apitesting.VerifyUpdateValidationEquivalence(t, ctx, evictionRequest, oldEvictionRequest, strategy, tc.errors, apitesting.WithSubResources("status")) + }) + } + + meta.RunConditionTestCases(t, ctx, field.NewPath("status", "conditions"), &lifecycle.EvictionRequest{}, strategy, func(obj *lifecycle.EvictionRequest, c []metav1.Condition) { + *obj = *mkValidEvictionRequest(func(r *lifecycle.EvictionRequest) { r.Status.Conditions = c }) + }) +} + +func mkValidEvictionRequest(tweaks ...func(obj *lifecycle.EvictionRequest)) *lifecycle.EvictionRequest { + obj := lifecycle.EvictionRequest{ + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "foo"}, + Spec: lifecycle.EvictionRequestSpec{ + Target: lifecycle.EvictionRequestTarget{ + Pod: &lifecycle.EvictionRequestPodReference{ + UID: validUID, + Name: "foo.pod", + }, + }, + Requester: "foo.example.com/bar", + Intent: lifecycle.EvictionRequestIntentEviction, + }, + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} + +func setName(name string) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Name = name + } +} +func clearTarget() func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Spec.Target.Pod = nil + } +} + +func setTarget(name, uid string) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Spec.Target.Pod = &lifecycle.EvictionRequestPodReference{ + UID: apimachinerytypes.UID(uid), + Name: name, + } + } +} +func setRequester(requester string) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Spec.Requester = requester + } +} +func setRequesterIntent(intent lifecycle.EvictionRequestIntent) func(obj *lifecycle.EvictionRequest) { + return func(obj *lifecycle.EvictionRequest) { + obj.Spec.Intent = intent + } +} + +func mkValidEvictionRequestStatus(responders int, tweaks ...func(obj *lifecycle.EvictionRequestStatus)) *lifecycle.EvictionRequestStatus { + obj := lifecycle.EvictionRequestStatus{ + ObservedGeneration: new(int64(1)), + } + for _, tweak := range tweaks { + tweak(&obj) + } + return &obj +} +func addCondition(clock utilsclock.PassiveClock, name lifecycle.EvictionConditionType, status metav1.ConditionStatus) func(obj *lifecycle.EvictionRequestStatus) { + return func(obj *lifecycle.EvictionRequestStatus) { + newCond := metav1.Condition{ + Type: string(name), + Status: status, + Reason: string(name) + "Reason", + LastTransitionTime: metav1.Time{Time: clock.Now()}, + } + obj.Conditions = append(obj.Conditions, newCond) + } +} +func addConditionsCount(clock utilsclock.PassiveClock, count int) func(obj *lifecycle.EvictionRequestStatus) { + return func(obj *lifecycle.EvictionRequestStatus) { + for i := range count { + addCondition(clock, lifecycle.EvictionConditionType(fmt.Sprintf("Condition%d", i)), metav1.ConditionTrue)(obj) + } + } +} + +func setObservedGeneration(generation *int64) func(obj *lifecycle.EvictionRequestStatus) { + return func(obj *lifecycle.EvictionRequestStatus) { + obj.ObservedGeneration = generation + } +} diff --git a/test/integration/etcd/data.go b/test/integration/etcd/data.go index a8541715cef..dffa8dd8326 100644 --- a/test/integration/etcd/data.go +++ b/test/integration/etcd/data.go @@ -330,6 +330,23 @@ func GetEtcdStorageDataForNamespaceServedAt(namespace string, v string, isEmulat }, // -- + // k8s.io/kubernetes/pkg/apis/lifecycle/v1alpha1 + gvr("lifecycle.k8s.io", "v1alpha1", "evictionrequests"): { + Stub: `{"metadata": {"name": "pod-eviction-request"}, "spec": {"target": {"pod": {"name": "my-workload", "uid": "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6"}}, "requester": "drain.foo.com/bar", "intent": "Eviction"}}`, + ExpectedEtcdPath: "/registry/evictionrequests/" + namespace + "/pod-eviction-request", + ExpectedGVK: gvkP("lifecycle.k8s.io", "v1alpha1", "EvictionRequest"), + IntroducedVersion: "1.37", + RemovedVersion: "1.43", + }, + gvr("lifecycle.k8s.io", "v1alpha1", "evictions"): { + Stub: `{"metadata": {"name": "pod-1-my-workload"}, "spec": {"target": {"pod": {"name": "my-workload", "uid": "3d7fdff1-3fe5-48b9-b106-1ee24b0277f6"}}}}`, + ExpectedEtcdPath: "/registry/evictions/" + namespace + "/pod-1-my-workload", + ExpectedGVK: gvkP("lifecycle.k8s.io", "v1alpha1", "Eviction"), + IntroducedVersion: "1.37", + RemovedVersion: "1.43", + }, + // -- + // k8s.io/kubernetes/pkg/apis/discovery/v1 gvr("discovery.k8s.io", "v1", "endpointslices"): { Stub: `{"metadata": {"name": "slicev1"}, "addressType": "IPv4", "protocol": "TCP", "ports": [], "endpoints": []}`,