From 0ba683a39f706bc195465449226ae4243e84a90e Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 13 May 2026 20:42:33 +0530 Subject: [PATCH 01/15] feat(api): add CheckpointSchedule CRD types Signed-off-by: Rupam-It --- api/v1/checkpointschedule_types.go | 83 +++++++ api/v1/zz_generated.deepcopy.go | 163 ++++++++++++++ .../criu.org_checkpointrestoreoperators.yaml | 60 ++++- .../bases/criu.org_checkpointschedules.yaml | 207 ++++++++++++++++++ 4 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 api/v1/checkpointschedule_types.go create mode 100644 config/crd/bases/criu.org_checkpointschedules.yaml diff --git a/api/v1/checkpointschedule_types.go b/api/v1/checkpointschedule_types.go new file mode 100644 index 00000000..8313c1cc --- /dev/null +++ b/api/v1/checkpointschedule_types.go @@ -0,0 +1,83 @@ +/* +Copyright 2026. + +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 v1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +type CheckpointIntent string + +const ( + Backup CheckpointIntent = "Backup" + PreEviction CheckpointIntent = "PreEviction" + ResourcePressure CheckpointIntent = "ResourcePressure" + Manual CheckpointIntent = "Manual" +) + +type CheckpointScheduleSpec struct { + Namespace string `json:"namespace"` + Selector metav1.LabelSelector `json:"selector"` + ContainerNames []string `json:"containerNames,omitempty"` + Intent CheckpointIntent `json:"intent"` + Triggers TriggersSpec `json:"triggers"` +} + +type TriggersSpec struct { + Schedule string `json:"schedule,omitempty"` + ResourceThreshold *ResourceThresholdSpec `json:"resourceThreshold,omitempty"` + OnKubernetesEvents []string `json:"onKubernetesEvents,omitempty"` + OnAnnotation bool `json:"onAnnotation,omitempty"` +} + +type ResourceThresholdSpec struct { + CPUPercent *int `json:"cpuPercent,omitempty"` + MemoryPercent *int `json:"memoryPercent,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` +} + +// CheckpointScheduleStatus defines the observed state of CheckpointSchedule +type CheckpointScheduleStatus struct { + LastCheckpointTime *metav1.Time `json:"lastCheckpointTime,omitempty"` + CheckpointsCreated int `json:"checkpointsCreated,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// CheckpointSchedule is the Schema for the checkpointschedules API +type CheckpointSchedule struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CheckpointScheduleSpec `json:"spec,omitempty"` + Status CheckpointScheduleStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// CheckpointScheduleList contains a list of CheckpointSchedule +type CheckpointScheduleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CheckpointSchedule `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CheckpointSchedule{}, &CheckpointScheduleList{}) +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 0b6bf35c..43051180 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -135,6 +136,113 @@ func (in *CheckpointRestoreOperatorStatus) DeepCopy() *CheckpointRestoreOperator return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CheckpointSchedule) DeepCopyInto(out *CheckpointSchedule) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CheckpointSchedule. +func (in *CheckpointSchedule) DeepCopy() *CheckpointSchedule { + if in == nil { + return nil + } + out := new(CheckpointSchedule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CheckpointSchedule) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CheckpointScheduleList) DeepCopyInto(out *CheckpointScheduleList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CheckpointSchedule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CheckpointScheduleList. +func (in *CheckpointScheduleList) DeepCopy() *CheckpointScheduleList { + if in == nil { + return nil + } + out := new(CheckpointScheduleList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CheckpointScheduleList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CheckpointScheduleSpec) DeepCopyInto(out *CheckpointScheduleSpec) { + *out = *in + in.Selector.DeepCopyInto(&out.Selector) + if in.ContainerNames != nil { + in, out := &in.ContainerNames, &out.ContainerNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Triggers.DeepCopyInto(&out.Triggers) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CheckpointScheduleSpec. +func (in *CheckpointScheduleSpec) DeepCopy() *CheckpointScheduleSpec { + if in == nil { + return nil + } + out := new(CheckpointScheduleSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CheckpointScheduleStatus) DeepCopyInto(out *CheckpointScheduleStatus) { + *out = *in + if in.LastCheckpointTime != nil { + in, out := &in.LastCheckpointTime, &out.LastCheckpointTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CheckpointScheduleStatus. +func (in *CheckpointScheduleStatus) DeepCopy() *CheckpointScheduleStatus { + if in == nil { + return nil + } + out := new(CheckpointScheduleStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContainerPolicySpec) DeepCopyInto(out *ContainerPolicySpec) { *out = *in @@ -294,3 +402,58 @@ func (in *PodPolicySpec) DeepCopy() *PodPolicySpec { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceThresholdSpec) DeepCopyInto(out *ResourceThresholdSpec) { + *out = *in + if in.CPUPercent != nil { + in, out := &in.CPUPercent, &out.CPUPercent + *out = new(int) + **out = **in + } + if in.MemoryPercent != nil { + in, out := &in.MemoryPercent, &out.MemoryPercent + *out = new(int) + **out = **in + } + if in.PollIntervalSeconds != nil { + in, out := &in.PollIntervalSeconds, &out.PollIntervalSeconds + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceThresholdSpec. +func (in *ResourceThresholdSpec) DeepCopy() *ResourceThresholdSpec { + if in == nil { + return nil + } + out := new(ResourceThresholdSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TriggersSpec) DeepCopyInto(out *TriggersSpec) { + *out = *in + if in.ResourceThreshold != nil { + in, out := &in.ResourceThreshold, &out.ResourceThreshold + *out = new(ResourceThresholdSpec) + (*in).DeepCopyInto(*out) + } + if in.OnKubernetesEvents != nil { + in, out := &in.OnKubernetesEvents, &out.OnKubernetesEvents + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggersSpec. +func (in *TriggersSpec) DeepCopy() *TriggersSpec { + if in == nil { + return nil + } + out := new(TriggersSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/criu.org_checkpointrestoreoperators.yaml b/config/crd/bases/criu.org_checkpointrestoreoperators.yaml index a235af18..64848a01 100644 --- a/config/crd/bases/criu.org_checkpointrestoreoperators.yaml +++ b/config/crd/bases/criu.org_checkpointrestoreoperators.yaml @@ -53,11 +53,19 @@ spec: container: type: string maxCheckpointSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxCheckpoints: type: integer maxTotalSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true namespace: type: string pod: @@ -69,7 +77,11 @@ spec: globalPolicy: properties: maxCheckpointSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxCheckpointsPerContainer: type: integer maxCheckpointsPerNamespace: @@ -77,11 +89,23 @@ spec: maxCheckpointsPerPod: type: integer maxTotalSizePerContainer: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxTotalSizePerNamespace: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxTotalSizePerPod: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true retainOrphan: type: boolean type: object @@ -89,11 +113,19 @@ spec: items: properties: maxCheckpointSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxCheckpoints: type: integer maxTotalSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true namespace: type: string retainOrphan: @@ -104,11 +136,19 @@ spec: items: properties: maxCheckpointSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true maxCheckpoints: type: integer maxTotalSize: - type: integer + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true namespace: type: string pod: diff --git a/config/crd/bases/criu.org_checkpointschedules.yaml b/config/crd/bases/criu.org_checkpointschedules.yaml new file mode 100644 index 00000000..48d7a7d7 --- /dev/null +++ b/config/crd/bases/criu.org_checkpointschedules.yaml @@ -0,0 +1,207 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.15.0 + name: checkpointschedules.criu.org +spec: + group: criu.org + names: + kind: CheckpointSchedule + listKind: CheckpointScheduleList + plural: checkpointschedules + singular: checkpointschedule + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: CheckpointSchedule is the Schema for the checkpointschedules + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + containerNames: + items: + type: string + type: array + intent: + type: string + namespace: + type: string + selector: + description: |- + A label selector is a label query over a set of resources. The result of matchLabels and + matchExpressions are ANDed. An empty label selector matches all objects. A null + label selector matches no objects. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + triggers: + properties: + onAnnotation: + type: boolean + onKubernetesEvents: + items: + type: string + type: array + resourceThreshold: + properties: + cpuPercent: + type: integer + memoryPercent: + type: integer + pollIntervalSeconds: + type: integer + type: object + schedule: + type: string + type: object + required: + - intent + - namespace + - selector + - triggers + type: object + status: + description: CheckpointScheduleStatus defines the observed state of CheckpointSchedule + properties: + checkpointsCreated: + type: integer + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastCheckpointTime: + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} From 0c9732ee69cb9a880a90c4e3c76e180c9b36743e Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Mon, 18 May 2026 12:56:50 +0530 Subject: [PATCH 02/15] feat(controller): add pod selector helper Signed-off-by: Rupam-It --- internal/controller/pod_selector.go | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 internal/controller/pod_selector.go diff --git a/internal/controller/pod_selector.go b/internal/controller/pod_selector.go new file mode 100644 index 00000000..89af1a0d --- /dev/null +++ b/internal/controller/pod_selector.go @@ -0,0 +1,57 @@ +package controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func getMatchingPods( + ctx context.Context, + c client.Client, + namespace string, + selector *metav1.LabelSelector, +) ([]corev1.Pod, error) { + + podList := &corev1.PodList{} + + labelSelector, err := metav1.LabelSelectorAsSelector(selector) + if err != nil { + return nil, err + } + err = c.List(ctx, podList, client.InNamespace(namespace), client.MatchingLabelsSelector{Selector: labelSelector}) + if err != nil { + return nil, err + } + + var runninPod []corev1.Pod + + for _, pod := range podList.Items { + if pod.Status.Phase == corev1.PodRunning { + runninPod = append(runninPod, pod) + } + } + return runninPod, nil + +} + +func filterContainers(pod corev1.Pod, containerNames []string) []corev1.Container { + if len(containerNames) == 0 { + return pod.Spec.Containers + } + + var result []corev1.Container + + for _, container := range pod.Spec.Containers { + for _, name := range containerNames { + if container.Name == name { + result = append(result, container) + break + } + } + } + + return result +} From 7cd0d0a4b0c215350b40051402abcd13b1f0a11b Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Tue, 19 May 2026 23:27:34 +0530 Subject: [PATCH 03/15] feat(controller): add checkpoint creator Signed-off-by: Rupam-It --- internal/controller/checkpoint_creator.go | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 internal/controller/checkpoint_creator.go diff --git a/internal/controller/checkpoint_creator.go b/internal/controller/checkpoint_creator.go new file mode 100644 index 00000000..f4aa17ce --- /dev/null +++ b/internal/controller/checkpoint_creator.go @@ -0,0 +1,55 @@ +package controller + +import ( + "context" + "fmt" + "net/http" + + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type CheckpointCreator struct { + client client.Client + restConfig *rest.Config +} + +func (cc *CheckpointCreator) createCheckpoint( + ctx context.Context, + nameSpace string, + podName string, + containerName string, + nodeName string, +) error { + logger := log.FromContext(ctx) + + // build URL + url := cc.restConfig.Host + "/api/v1/nodes/" + nodeName + "/proxy/checkpoint/" + nameSpace + "/" + podName + "/" + containerName + logger.Info("creating checkpoint", "url", url, "pod", podName, "container", containerName) + + // make POST request + httpClient, err := rest.HTTPClientFor(cc.restConfig) + if err != nil { + return err + } + resp, err := httpClient.Post(url, "application/json", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + // handle response + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("checkpoint failed for %s/%s/%s: status %d", + nameSpace, podName, containerName, resp.StatusCode) + } + return nil +} + +func NewCheckpointCreator(c client.Client, restConfig *rest.Config) *CheckpointCreator { + return &CheckpointCreator{ + client: c, + restConfig: restConfig, + } +} From 83888928424683dd089863d9af9fc8b720e6e164 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 00:16:19 +0530 Subject: [PATCH 04/15] chore(deps): add robfig/cron dependency Signed-off-by: Rupam-It --- go.mod | 1 + go.sum | 2 ++ vendor/modules.txt | 2 ++ 3 files changed, 5 insertions(+) diff --git a/go.mod b/go.mod index 36eb70f6..2778a8b7 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 + github.com/robfig/cron/v3 v3.0.1 k8s.io/api v0.33.2 k8s.io/apimachinery v0.33.2 k8s.io/client-go v0.33.2 diff --git a/go.sum b/go.sum index 8acdc213..f0efae9c 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/vendor/modules.txt b/vendor/modules.txt index c9ab16cb..dd8c2919 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -198,6 +198,8 @@ github.com/prometheus/common/model github.com/prometheus/procfs github.com/prometheus/procfs/internal/fs github.com/prometheus/procfs/internal/util +# github.com/robfig/cron/v3 v3.0.1 +## explicit; go 1.12 # github.com/sirupsen/logrus v1.9.3 ## explicit; go 1.13 github.com/sirupsen/logrus From 961fbf50d35cc26e3a0992662dbcc1de3aac967b Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 00:17:09 +0530 Subject: [PATCH 05/15] feat(controller): add schedule-based checkpoint trigger Signed-off-by: Rupam-It --- internal/controller/schedule_trigger.go | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 internal/controller/schedule_trigger.go diff --git a/internal/controller/schedule_trigger.go b/internal/controller/schedule_trigger.go new file mode 100644 index 00000000..c157b482 --- /dev/null +++ b/internal/controller/schedule_trigger.go @@ -0,0 +1,74 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" + "github.com/robfig/cron/v3" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type ScheduleTrigger struct { + scheduler *cron.Cron + creator *CheckpointCreator + client client.Client + schedule *v1.CheckpointSchedule +} + +func NewScheduleTrigger(c client.Client, creator *CheckpointCreator, schedule *v1.CheckpointSchedule) *ScheduleTrigger { + return &ScheduleTrigger{ + scheduler: cron.New(), + creator: creator, + client: c, + schedule: schedule, + } +} + +func (st *ScheduleTrigger) Start(ctx context.Context) error { + logger := log.FromContext(ctx) + + _, err := st.scheduler.AddFunc(st.schedule.Spec.Triggers.Schedule, func() { + pods, err := getMatchingPods(ctx, st.client, st.schedule.Spec.Namespace, &st.schedule.Spec.Selector) + if err != nil { + logger.Error(err, "failed to get matching pods") + return + } + + for _, pod := range pods { + containers := filterContainers(pod, st.schedule.Spec.ContainerNames) + for _, container := range containers { + err := st.creator.createCheckpoint(ctx, pod.Namespace, pod.Name, container.Name, pod.Spec.NodeName) + if err != nil { + logger.Error(err, "failed to create checkpoint", "pod", pod.Name, "container", container.Name) + } + } + } + }) + if err != nil { + return err + } + + st.scheduler.Start() + logger.Info("schedule trigger started", "schedule", st.schedule.Spec.Triggers.Schedule) + return nil +} + +func (st *ScheduleTrigger) Stop() { + st.scheduler.Stop() +} From 36633f32ecb13014f400aa0ba20b8bd08102e784 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 00:18:05 +0530 Subject: [PATCH 06/15] chore: update license header Signed-off-by: Rupam-It --- internal/controller/checkpoint_creator.go | 15 +++++++++++++++ internal/controller/pod_selector.go | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/controller/checkpoint_creator.go b/internal/controller/checkpoint_creator.go index f4aa17ce..3f13bc12 100644 --- a/internal/controller/checkpoint_creator.go +++ b/internal/controller/checkpoint_creator.go @@ -1,3 +1,18 @@ +/* +Copyright 2026. + +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 controller import ( diff --git a/internal/controller/pod_selector.go b/internal/controller/pod_selector.go index 89af1a0d..a5a400f5 100644 --- a/internal/controller/pod_selector.go +++ b/internal/controller/pod_selector.go @@ -1,3 +1,18 @@ +/* +Copyright 2026. + +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 controller import ( From a48d1cc776a82f0b6d5abf177747bf8d82568ecd Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 12:57:55 +0530 Subject: [PATCH 07/15] perf(controller): optimize container filtering to O(n+m) Signed-off-by: Rupam-It --- internal/controller/pod_selector.go | 12 +++++++----- internal/controller/schedule_trigger.go | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/internal/controller/pod_selector.go b/internal/controller/pod_selector.go index a5a400f5..e0200c24 100644 --- a/internal/controller/pod_selector.go +++ b/internal/controller/pod_selector.go @@ -59,12 +59,14 @@ func filterContainers(pod corev1.Pod, containerNames []string) []corev1.Containe var result []corev1.Container + nameSet := make(map[string]bool) + for _, name := range containerNames { + nameSet[name] = true + } + for _, container := range pod.Spec.Containers { - for _, name := range containerNames { - if container.Name == name { - result = append(result, container) - break - } + if nameSet[container.Name] { + result = append(result, container) } } diff --git a/internal/controller/schedule_trigger.go b/internal/controller/schedule_trigger.go index c157b482..1b72ee89 100644 --- a/internal/controller/schedule_trigger.go +++ b/internal/controller/schedule_trigger.go @@ -17,6 +17,7 @@ package controller import ( "context" + "sync" v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" "github.com/robfig/cron/v3" @@ -50,15 +51,29 @@ func (st *ScheduleTrigger) Start(ctx context.Context) error { return } + containerSet := make(map[string]struct{}, len(st.schedule.Spec.ContainerNames)) + for _, name := range st.schedule.Spec.ContainerNames { + containerSet[name] = struct{}{} + } + + var wg sync.WaitGroup for _, pod := range pods { - containers := filterContainers(pod, st.schedule.Spec.ContainerNames) - for _, container := range containers { - err := st.creator.createCheckpoint(ctx, pod.Namespace, pod.Name, container.Name, pod.Spec.NodeName) - if err != nil { - logger.Error(err, "failed to create checkpoint", "pod", pod.Name, "container", container.Name) + for _, c := range pod.Spec.Containers { + if len(containerSet) > 0 { + if _, ok := containerSet[c.Name]; !ok { + continue + } } + wg.Add(1) + go func(ns, podName, containerName, nodeName string) { + defer wg.Done() + if err := st.creator.createCheckpoint(ctx, ns, podName, containerName, nodeName); err != nil { + logger.Error(err, "failed to create checkpoint", "pod", podName, "container", containerName) + } + }(pod.Namespace, pod.Name, c.Name, pod.Spec.NodeName) } } + wg.Wait() }) if err != nil { return err From a7c799324929db0774551c512259d19fac64a4fc Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 15:00:14 +0530 Subject: [PATCH 08/15] test(controller): add unit tests for checkpoint creator and pod selector Signed-off-by: Rupam-It --- internal/controller/checkpoint_creator.go | 4 + .../controller/checkpoint_creator_test.go | 69 ++++++++ internal/controller/pod_selector_test.go | 108 +++++++++++++ internal/controller/schedule_trigger.go | 68 ++++---- internal/controller/schedule_trigger_test.go | 150 ++++++++++++++++++ internal/controller/suite_test.go | 11 +- 6 files changed, 376 insertions(+), 34 deletions(-) create mode 100644 internal/controller/checkpoint_creator_test.go create mode 100644 internal/controller/pod_selector_test.go create mode 100644 internal/controller/schedule_trigger_test.go diff --git a/internal/controller/checkpoint_creator.go b/internal/controller/checkpoint_creator.go index 3f13bc12..5746638c 100644 --- a/internal/controller/checkpoint_creator.go +++ b/internal/controller/checkpoint_creator.go @@ -25,6 +25,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) +type Checkpointer interface { + createCheckpoint(ctx context.Context, ns, podName, containerName, nodeName string) error +} + type CheckpointCreator struct { client client.Client restConfig *rest.Config diff --git a/internal/controller/checkpoint_creator_test.go b/internal/controller/checkpoint_creator_test.go new file mode 100644 index 00000000..046663a6 --- /dev/null +++ b/internal/controller/checkpoint_creator_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/rest" +) + +var _ = Describe("CheckpointCreator", func() { + var ( + server *httptest.Server + creator *CheckpointCreator + lastPath string + lastMethod string + statusCode int + ) + + BeforeEach(func() { + statusCode = http.StatusOK + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + lastMethod = r.Method + w.WriteHeader(statusCode) + })) + creator = NewCheckpointCreator(nil, &rest.Config{Host: server.URL}) + }) + + AfterEach(func() { + server.Close() + }) + + It("POSTs to the correct kubelet checkpoint URL", func() { + err := creator.createCheckpoint(context.Background(), "default", "my-pod", "my-container", "my-node") + Expect(err).NotTo(HaveOccurred()) + Expect(lastMethod).To(Equal(http.MethodPost)) + Expect(lastPath).To(Equal("/api/v1/nodes/my-node/proxy/checkpoint/default/my-pod/my-container")) + }) + + It("returns nil on HTTP 200", func() { + err := creator.createCheckpoint(context.Background(), "ns", "pod", "ctr", "node") + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an error on non-200 status", func() { + statusCode = http.StatusInternalServerError + err := creator.createCheckpoint(context.Background(), "ns", "pod", "ctr", "node") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("checkpoint failed")) + }) +}) diff --git a/internal/controller/pod_selector_test.go b/internal/controller/pod_selector_test.go new file mode 100644 index 00000000..8d557e71 --- /dev/null +++ b/internal/controller/pod_selector_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("filterContainers", func() { + var pod corev1.Pod + + BeforeEach(func() { + pod = corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app"}, + {Name: "sidecar"}, + {Name: "proxy"}, + }, + }, + } + }) + + It("returns all containers when the names list is empty", func() { + Expect(filterContainers(pod, nil)).To(HaveLen(3)) + Expect(filterContainers(pod, []string{})).To(HaveLen(3)) + }) + + It("returns only the named containers", func() { + result := filterContainers(pod, []string{"app", "proxy"}) + Expect(result).To(HaveLen(2)) + Expect([]string{result[0].Name, result[1].Name}).To(ConsistOf("app", "proxy")) + }) + + It("returns empty when no container matches", func() { + Expect(filterContainers(pod, []string{"unknown"})).To(BeEmpty()) + }) +}) + +var _ = Describe("getMatchingPods", func() { + var fakeClient fake.ClientBuilder + + BeforeEach(func() { + runningPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "running-pod", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + pendingPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pending-pod", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodPending}, + } + otherNsPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-ns-pod", + Namespace: "other", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + fakeClient = *fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithObjects(runningPod, pendingPod, otherNsPod). + WithStatusSubresource(runningPod, pendingPod, otherNsPod) + }) + + It("returns only Running pods in the given namespace", func() { + selector := &metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}} + pods, err := getMatchingPods(context.Background(), fakeClient.Build(), "default", selector) + Expect(err).NotTo(HaveOccurred()) + Expect(pods).To(HaveLen(1)) + Expect(pods[0].Name).To(Equal("running-pod")) + }) + + It("returns empty when no pods match the selector", func() { + selector := &metav1.LabelSelector{MatchLabels: map[string]string{"app": "nonexistent"}} + pods, err := getMatchingPods(context.Background(), fakeClient.Build(), "default", selector) + Expect(err).NotTo(HaveOccurred()) + Expect(pods).To(BeEmpty()) + }) +}) diff --git a/internal/controller/schedule_trigger.go b/internal/controller/schedule_trigger.go index 1b72ee89..f63b20fc 100644 --- a/internal/controller/schedule_trigger.go +++ b/internal/controller/schedule_trigger.go @@ -27,12 +27,12 @@ import ( type ScheduleTrigger struct { scheduler *cron.Cron - creator *CheckpointCreator + creator Checkpointer client client.Client schedule *v1.CheckpointSchedule } -func NewScheduleTrigger(c client.Client, creator *CheckpointCreator, schedule *v1.CheckpointSchedule) *ScheduleTrigger { +func NewScheduleTrigger(c client.Client, creator Checkpointer, schedule *v1.CheckpointSchedule) *ScheduleTrigger { return &ScheduleTrigger{ scheduler: cron.New(), creator: creator, @@ -45,35 +45,7 @@ func (st *ScheduleTrigger) Start(ctx context.Context) error { logger := log.FromContext(ctx) _, err := st.scheduler.AddFunc(st.schedule.Spec.Triggers.Schedule, func() { - pods, err := getMatchingPods(ctx, st.client, st.schedule.Spec.Namespace, &st.schedule.Spec.Selector) - if err != nil { - logger.Error(err, "failed to get matching pods") - return - } - - containerSet := make(map[string]struct{}, len(st.schedule.Spec.ContainerNames)) - for _, name := range st.schedule.Spec.ContainerNames { - containerSet[name] = struct{}{} - } - - var wg sync.WaitGroup - for _, pod := range pods { - for _, c := range pod.Spec.Containers { - if len(containerSet) > 0 { - if _, ok := containerSet[c.Name]; !ok { - continue - } - } - wg.Add(1) - go func(ns, podName, containerName, nodeName string) { - defer wg.Done() - if err := st.creator.createCheckpoint(ctx, ns, podName, containerName, nodeName); err != nil { - logger.Error(err, "failed to create checkpoint", "pod", podName, "container", containerName) - } - }(pod.Namespace, pod.Name, c.Name, pod.Spec.NodeName) - } - } - wg.Wait() + st.runCheckpoints(ctx) }) if err != nil { return err @@ -87,3 +59,37 @@ func (st *ScheduleTrigger) Start(ctx context.Context) error { func (st *ScheduleTrigger) Stop() { st.scheduler.Stop() } + +func (st *ScheduleTrigger) runCheckpoints(ctx context.Context) { + logger := log.FromContext(ctx) + + pods, err := getMatchingPods(ctx, st.client, st.schedule.Spec.Namespace, &st.schedule.Spec.Selector) + if err != nil { + logger.Error(err, "failed to get matching pods") + return + } + + containerSet := make(map[string]struct{}, len(st.schedule.Spec.ContainerNames)) + for _, name := range st.schedule.Spec.ContainerNames { + containerSet[name] = struct{}{} + } + + var wg sync.WaitGroup + for _, pod := range pods { + for _, c := range pod.Spec.Containers { + if len(containerSet) > 0 { + if _, ok := containerSet[c.Name]; !ok { + continue + } + } + wg.Add(1) + go func(ns, podName, containerName, nodeName string) { + defer wg.Done() + if err := st.creator.createCheckpoint(ctx, ns, podName, containerName, nodeName); err != nil { + logger.Error(err, "failed to create checkpoint", "pod", podName, "container", containerName) + } + }(pod.Namespace, pod.Name, c.Name, pod.Spec.NodeName) + } + } + wg.Wait() +} diff --git a/internal/controller/schedule_trigger_test.go b/internal/controller/schedule_trigger_test.go new file mode 100644 index 00000000..dc538ca8 --- /dev/null +++ b/internal/controller/schedule_trigger_test.go @@ -0,0 +1,150 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" +) + +// mockCheckpointer records every createCheckpoint call for assertions. +type mockCheckpointer struct { + mu sync.Mutex + calls []checkpointCall +} + +type checkpointCall struct { + ns, pod, container, node string +} + +func (m *mockCheckpointer) createCheckpoint(_ context.Context, ns, pod, container, node string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, checkpointCall{ns, pod, container, node}) + return nil +} + +func makeTrigger(mock *mockCheckpointer, containerNames []string, pods ...*corev1.Pod) *ScheduleTrigger { + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + for _, p := range pods { + builder = builder.WithObjects(p).WithStatusSubresource(p) + } + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Namespace: "default", + ContainerNames: containerNames, + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}}, + Triggers: v1.TriggersSpec{Schedule: "@every 1h"}, + }, + } + return NewScheduleTrigger(builder.Build(), mock, sched) +} + +var _ = Describe("ScheduleTrigger.runCheckpoints", func() { + var mock *mockCheckpointer + + BeforeEach(func() { + mock = &mockCheckpointer{} + }) + + It("calls createCheckpoint for every container in every matching Running pod", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}, {Name: "sidecar"}}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + trigger := makeTrigger(mock, nil, pod) + trigger.runCheckpoints(context.Background()) + + Expect(mock.calls).To(HaveLen(2)) + Expect(mock.calls).To(ContainElements( + checkpointCall{ns: "default", pod: "pod-1", container: "app"}, + checkpointCall{ns: "default", pod: "pod-1", container: "sidecar"}, + )) + }) + + It("only checkpoints containers listed in ContainerNames", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}, {Name: "sidecar"}}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + trigger := makeTrigger(mock, []string{"app"}, pod) + trigger.runCheckpoints(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].container).To(Equal("app")) + }) + + It("makes no checkpoint calls when there are no Running pods", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-pending", Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}}}, + Status: corev1.PodStatus{Phase: corev1.PodPending}, + } + trigger := makeTrigger(mock, nil, pod) + trigger.runCheckpoints(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) +}) + +var _ = Describe("ScheduleTrigger Start/Stop", func() { + It("starts and stops without error", func() { + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Namespace: "default", + Selector: metav1.LabelSelector{}, + Triggers: v1.TriggersSpec{Schedule: "@every 1h"}, + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + trigger := NewScheduleTrigger(fakeClient, &mockCheckpointer{}, sched) + + Expect(trigger.Start(context.Background())).To(Succeed()) + trigger.Stop() + }) + + It("returns an error for an invalid cron expression", func() { + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Triggers: v1.TriggersSpec{Schedule: "not-a-valid-cron"}, + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() + trigger := NewScheduleTrigger(fakeClient, &mockCheckpointer{}, sched) + + Expect(trigger.Start(context.Background())).To(HaveOccurred()) + }) +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 207b9589..6faf573b 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -59,8 +59,11 @@ var _ = BeforeSuite(func() { var err error // cfg is defined in this file globally. cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) + if err != nil { + // envtest binaries (etcd, kube-apiserver) not present; integration tests will be skipped. + GinkgoWriter.Printf("envtest not available, skipping integration setup: %v\n", err) + return + } err = criuorgv1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) @@ -70,10 +73,12 @@ var _ = BeforeSuite(func() { k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) - }) var _ = AfterSuite(func() { + if cfg == nil { + return + } By("tearing down the test environment") err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) From f68edb918f79226e338793dcb1e2d43a20be9f88 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 15:02:28 +0530 Subject: [PATCH 09/15] chore(deps): vendor dependencies Signed-off-by: Rupam-It --- api/v1/checkpointschedule_types.go | 6 +- vendor/github.com/robfig/cron/v3/.gitignore | 22 + vendor/github.com/robfig/cron/v3/.travis.yml | 1 + vendor/github.com/robfig/cron/v3/LICENSE | 21 + vendor/github.com/robfig/cron/v3/README.md | 125 ++ vendor/github.com/robfig/cron/v3/chain.go | 92 + .../robfig/cron/v3/constantdelay.go | 27 + vendor/github.com/robfig/cron/v3/cron.go | 355 ++++ vendor/github.com/robfig/cron/v3/doc.go | 231 +++ vendor/github.com/robfig/cron/v3/logger.go | 86 + vendor/github.com/robfig/cron/v3/option.go | 45 + vendor/github.com/robfig/cron/v3/parser.go | 434 +++++ vendor/github.com/robfig/cron/v3/spec.go | 188 ++ .../k8s.io/apimachinery/pkg/util/rand/rand.go | 127 ++ vendor/modules.txt | 5 + .../pkg/client/fake/client.go | 1602 +++++++++++++++++ .../controller-runtime/pkg/client/fake/doc.go | 38 + .../pkg/client/interceptor/intercept.go | 166 ++ .../pkg/internal/objectutil/objectutil.go | 42 + 19 files changed, 3610 insertions(+), 3 deletions(-) create mode 100644 vendor/github.com/robfig/cron/v3/.gitignore create mode 100644 vendor/github.com/robfig/cron/v3/.travis.yml create mode 100644 vendor/github.com/robfig/cron/v3/LICENSE create mode 100644 vendor/github.com/robfig/cron/v3/README.md create mode 100644 vendor/github.com/robfig/cron/v3/chain.go create mode 100644 vendor/github.com/robfig/cron/v3/constantdelay.go create mode 100644 vendor/github.com/robfig/cron/v3/cron.go create mode 100644 vendor/github.com/robfig/cron/v3/doc.go create mode 100644 vendor/github.com/robfig/cron/v3/logger.go create mode 100644 vendor/github.com/robfig/cron/v3/option.go create mode 100644 vendor/github.com/robfig/cron/v3/parser.go create mode 100644 vendor/github.com/robfig/cron/v3/spec.go create mode 100644 vendor/k8s.io/apimachinery/pkg/util/rand/rand.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go diff --git a/api/v1/checkpointschedule_types.go b/api/v1/checkpointschedule_types.go index 8313c1cc..f922997f 100644 --- a/api/v1/checkpointschedule_types.go +++ b/api/v1/checkpointschedule_types.go @@ -38,10 +38,10 @@ type CheckpointScheduleSpec struct { } type TriggersSpec struct { - Schedule string `json:"schedule,omitempty"` + Schedule string `json:"schedule,omitempty"` ResourceThreshold *ResourceThresholdSpec `json:"resourceThreshold,omitempty"` - OnKubernetesEvents []string `json:"onKubernetesEvents,omitempty"` - OnAnnotation bool `json:"onAnnotation,omitempty"` + OnKubernetesEvents []string `json:"onKubernetesEvents,omitempty"` + OnAnnotation bool `json:"onAnnotation,omitempty"` } type ResourceThresholdSpec struct { diff --git a/vendor/github.com/robfig/cron/v3/.gitignore b/vendor/github.com/robfig/cron/v3/.gitignore new file mode 100644 index 00000000..00268614 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/.gitignore @@ -0,0 +1,22 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/vendor/github.com/robfig/cron/v3/.travis.yml b/vendor/github.com/robfig/cron/v3/.travis.yml new file mode 100644 index 00000000..4f2ee4d9 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/.travis.yml @@ -0,0 +1 @@ +language: go diff --git a/vendor/github.com/robfig/cron/v3/LICENSE b/vendor/github.com/robfig/cron/v3/LICENSE new file mode 100644 index 00000000..3a0f627f --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/LICENSE @@ -0,0 +1,21 @@ +Copyright (C) 2012 Rob Figueiredo +All Rights Reserved. + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/robfig/cron/v3/README.md b/vendor/github.com/robfig/cron/v3/README.md new file mode 100644 index 00000000..984c537c --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/README.md @@ -0,0 +1,125 @@ +[![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron) +[![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron) + +# cron + +Cron V3 has been released! + +To download the specific tagged release, run: + + go get github.com/robfig/cron/v3@v3.0.0 + +Import it in your program as: + + import "github.com/robfig/cron/v3" + +It requires Go 1.11 or later due to usage of Go Modules. + +Refer to the documentation here: +http://godoc.org/github.com/robfig/cron + +The rest of this document describes the the advances in v3 and a list of +breaking changes for users that wish to upgrade from an earlier version. + +## Upgrading to v3 (June 2019) + +cron v3 is a major upgrade to the library that addresses all outstanding bugs, +feature requests, and rough edges. It is based on a merge of master which +contains various fixes to issues found over the years and the v2 branch which +contains some backwards-incompatible features like the ability to remove cron +jobs. In addition, v3 adds support for Go Modules, cleans up rough edges like +the timezone support, and fixes a number of bugs. + +New features: + +- Support for Go modules. Callers must now import this library as + `github.com/robfig/cron/v3`, instead of `gopkg.in/...` + +- Fixed bugs: + - 0f01e6b parser: fix combining of Dow and Dom (#70) + - dbf3220 adjust times when rolling the clock forward to handle non-existent midnight (#157) + - eeecf15 spec_test.go: ensure an error is returned on 0 increment (#144) + - 70971dc cron.Entries(): update request for snapshot to include a reply channel (#97) + - 1cba5e6 cron: fix: removing a job causes the next scheduled job to run too late (#206) + +- Standard cron spec parsing by default (first field is "minute"), with an easy + way to opt into the seconds field (quartz-compatible). Although, note that the + year field (optional in Quartz) is not supported. + +- Extensible, key/value logging via an interface that complies with + the https://github.com/go-logr/logr project. + +- The new Chain & JobWrapper types allow you to install "interceptors" to add + cross-cutting behavior like the following: + - Recover any panics from jobs + - Delay a job's execution if the previous run hasn't completed yet + - Skip a job's execution if the previous run hasn't completed yet + - Log each job's invocations + - Notification when jobs are completed + +It is backwards incompatible with both v1 and v2. These updates are required: + +- The v1 branch accepted an optional seconds field at the beginning of the cron + spec. This is non-standard and has led to a lot of confusion. The new default + parser conforms to the standard as described by [the Cron wikipedia page]. + + UPDATING: To retain the old behavior, construct your Cron with a custom + parser: + + // Seconds field, required + cron.New(cron.WithSeconds()) + + // Seconds field, optional + cron.New( + cron.WithParser( + cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)) + +- The Cron type now accepts functional options on construction rather than the + previous ad-hoc behavior modification mechanisms (setting a field, calling a setter). + + UPDATING: Code that sets Cron.ErrorLogger or calls Cron.SetLocation must be + updated to provide those values on construction. + +- CRON_TZ is now the recommended way to specify the timezone of a single + schedule, which is sanctioned by the specification. The legacy "TZ=" prefix + will continue to be supported since it is unambiguous and easy to do so. + + UPDATING: No update is required. + +- By default, cron will no longer recover panics in jobs that it runs. + Recovering can be surprising (see issue #192) and seems to be at odds with + typical behavior of libraries. Relatedly, the `cron.WithPanicLogger` option + has been removed to accommodate the more general JobWrapper type. + + UPDATING: To opt into panic recovery and configure the panic logger: + + cron.New(cron.WithChain( + cron.Recover(logger), // or use cron.DefaultLogger + )) + +- In adding support for https://github.com/go-logr/logr, `cron.WithVerboseLogger` was + removed, since it is duplicative with the leveled logging. + + UPDATING: Callers should use `WithLogger` and specify a logger that does not + discard `Info` logs. For convenience, one is provided that wraps `*log.Logger`: + + cron.New( + cron.WithLogger(cron.VerbosePrintfLogger(logger))) + + +### Background - Cron spec format + +There are two cron spec formats in common usage: + +- The "standard" cron format, described on [the Cron wikipedia page] and used by + the cron Linux system utility. + +- The cron format used by [the Quartz Scheduler], commonly used for scheduled + jobs in Java software + +[the Cron wikipedia page]: https://en.wikipedia.org/wiki/Cron +[the Quartz Scheduler]: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/tutorial-lesson-06.html + +The original version of this package included an optional "seconds" field, which +made it incompatible with both of these formats. Now, the "standard" format is +the default format accepted, and the Quartz format is opt-in. diff --git a/vendor/github.com/robfig/cron/v3/chain.go b/vendor/github.com/robfig/cron/v3/chain.go new file mode 100644 index 00000000..9565b418 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/chain.go @@ -0,0 +1,92 @@ +package cron + +import ( + "fmt" + "runtime" + "sync" + "time" +) + +// JobWrapper decorates the given Job with some behavior. +type JobWrapper func(Job) Job + +// Chain is a sequence of JobWrappers that decorates submitted jobs with +// cross-cutting behaviors like logging or synchronization. +type Chain struct { + wrappers []JobWrapper +} + +// NewChain returns a Chain consisting of the given JobWrappers. +func NewChain(c ...JobWrapper) Chain { + return Chain{c} +} + +// Then decorates the given job with all JobWrappers in the chain. +// +// This: +// NewChain(m1, m2, m3).Then(job) +// is equivalent to: +// m1(m2(m3(job))) +func (c Chain) Then(j Job) Job { + for i := range c.wrappers { + j = c.wrappers[len(c.wrappers)-i-1](j) + } + return j +} + +// Recover panics in wrapped jobs and log them with the provided logger. +func Recover(logger Logger) JobWrapper { + return func(j Job) Job { + return FuncJob(func() { + defer func() { + if r := recover(); r != nil { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + logger.Error(err, "panic", "stack", "...\n"+string(buf)) + } + }() + j.Run() + }) + } +} + +// DelayIfStillRunning serializes jobs, delaying subsequent runs until the +// previous one is complete. Jobs running after a delay of more than a minute +// have the delay logged at Info. +func DelayIfStillRunning(logger Logger) JobWrapper { + return func(j Job) Job { + var mu sync.Mutex + return FuncJob(func() { + start := time.Now() + mu.Lock() + defer mu.Unlock() + if dur := time.Since(start); dur > time.Minute { + logger.Info("delay", "duration", dur) + } + j.Run() + }) + } +} + +// SkipIfStillRunning skips an invocation of the Job if a previous invocation is +// still running. It logs skips to the given logger at Info level. +func SkipIfStillRunning(logger Logger) JobWrapper { + return func(j Job) Job { + var ch = make(chan struct{}, 1) + ch <- struct{}{} + return FuncJob(func() { + select { + case v := <-ch: + j.Run() + ch <- v + default: + logger.Info("skip") + } + }) + } +} diff --git a/vendor/github.com/robfig/cron/v3/constantdelay.go b/vendor/github.com/robfig/cron/v3/constantdelay.go new file mode 100644 index 00000000..cd6e7b1b --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/constantdelay.go @@ -0,0 +1,27 @@ +package cron + +import "time" + +// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes". +// It does not support jobs more frequent than once a second. +type ConstantDelaySchedule struct { + Delay time.Duration +} + +// Every returns a crontab Schedule that activates once every duration. +// Delays of less than a second are not supported (will round up to 1 second). +// Any fields less than a Second are truncated. +func Every(duration time.Duration) ConstantDelaySchedule { + if duration < time.Second { + duration = time.Second + } + return ConstantDelaySchedule{ + Delay: duration - time.Duration(duration.Nanoseconds())%time.Second, + } +} + +// Next returns the next time this should be run. +// This rounds so that the next activation time will be on the second. +func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time { + return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond) +} diff --git a/vendor/github.com/robfig/cron/v3/cron.go b/vendor/github.com/robfig/cron/v3/cron.go new file mode 100644 index 00000000..c7e91766 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/cron.go @@ -0,0 +1,355 @@ +package cron + +import ( + "context" + "sort" + "sync" + "time" +) + +// Cron keeps track of any number of entries, invoking the associated func as +// specified by the schedule. It may be started, stopped, and the entries may +// be inspected while running. +type Cron struct { + entries []*Entry + chain Chain + stop chan struct{} + add chan *Entry + remove chan EntryID + snapshot chan chan []Entry + running bool + logger Logger + runningMu sync.Mutex + location *time.Location + parser ScheduleParser + nextID EntryID + jobWaiter sync.WaitGroup +} + +// ScheduleParser is an interface for schedule spec parsers that return a Schedule +type ScheduleParser interface { + Parse(spec string) (Schedule, error) +} + +// Job is an interface for submitted cron jobs. +type Job interface { + Run() +} + +// Schedule describes a job's duty cycle. +type Schedule interface { + // Next returns the next activation time, later than the given time. + // Next is invoked initially, and then each time the job is run. + Next(time.Time) time.Time +} + +// EntryID identifies an entry within a Cron instance +type EntryID int + +// Entry consists of a schedule and the func to execute on that schedule. +type Entry struct { + // ID is the cron-assigned ID of this entry, which may be used to look up a + // snapshot or remove it. + ID EntryID + + // Schedule on which this job should be run. + Schedule Schedule + + // Next time the job will run, or the zero time if Cron has not been + // started or this entry's schedule is unsatisfiable + Next time.Time + + // Prev is the last time this job was run, or the zero time if never. + Prev time.Time + + // WrappedJob is the thing to run when the Schedule is activated. + WrappedJob Job + + // Job is the thing that was submitted to cron. + // It is kept around so that user code that needs to get at the job later, + // e.g. via Entries() can do so. + Job Job +} + +// Valid returns true if this is not the zero entry. +func (e Entry) Valid() bool { return e.ID != 0 } + +// byTime is a wrapper for sorting the entry array by time +// (with zero time at the end). +type byTime []*Entry + +func (s byTime) Len() int { return len(s) } +func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s byTime) Less(i, j int) bool { + // Two zero times should return false. + // Otherwise, zero is "greater" than any other time. + // (To sort it at the end of the list.) + if s[i].Next.IsZero() { + return false + } + if s[j].Next.IsZero() { + return true + } + return s[i].Next.Before(s[j].Next) +} + +// New returns a new Cron job runner, modified by the given options. +// +// Available Settings +// +// Time Zone +// Description: The time zone in which schedules are interpreted +// Default: time.Local +// +// Parser +// Description: Parser converts cron spec strings into cron.Schedules. +// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron +// +// Chain +// Description: Wrap submitted jobs to customize behavior. +// Default: A chain that recovers panics and logs them to stderr. +// +// See "cron.With*" to modify the default behavior. +func New(opts ...Option) *Cron { + c := &Cron{ + entries: nil, + chain: NewChain(), + add: make(chan *Entry), + stop: make(chan struct{}), + snapshot: make(chan chan []Entry), + remove: make(chan EntryID), + running: false, + runningMu: sync.Mutex{}, + logger: DefaultLogger, + location: time.Local, + parser: standardParser, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// FuncJob is a wrapper that turns a func() into a cron.Job +type FuncJob func() + +func (f FuncJob) Run() { f() } + +// AddFunc adds a func to the Cron to be run on the given schedule. +// The spec is parsed using the time zone of this Cron instance as the default. +// An opaque ID is returned that can be used to later remove it. +func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) { + return c.AddJob(spec, FuncJob(cmd)) +} + +// AddJob adds a Job to the Cron to be run on the given schedule. +// The spec is parsed using the time zone of this Cron instance as the default. +// An opaque ID is returned that can be used to later remove it. +func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) { + schedule, err := c.parser.Parse(spec) + if err != nil { + return 0, err + } + return c.Schedule(schedule, cmd), nil +} + +// Schedule adds a Job to the Cron to be run on the given schedule. +// The job is wrapped with the configured Chain. +func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID { + c.runningMu.Lock() + defer c.runningMu.Unlock() + c.nextID++ + entry := &Entry{ + ID: c.nextID, + Schedule: schedule, + WrappedJob: c.chain.Then(cmd), + Job: cmd, + } + if !c.running { + c.entries = append(c.entries, entry) + } else { + c.add <- entry + } + return entry.ID +} + +// Entries returns a snapshot of the cron entries. +func (c *Cron) Entries() []Entry { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + replyChan := make(chan []Entry, 1) + c.snapshot <- replyChan + return <-replyChan + } + return c.entrySnapshot() +} + +// Location gets the time zone location +func (c *Cron) Location() *time.Location { + return c.location +} + +// Entry returns a snapshot of the given entry, or nil if it couldn't be found. +func (c *Cron) Entry(id EntryID) Entry { + for _, entry := range c.Entries() { + if id == entry.ID { + return entry + } + } + return Entry{} +} + +// Remove an entry from being run in the future. +func (c *Cron) Remove(id EntryID) { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + c.remove <- id + } else { + c.removeEntry(id) + } +} + +// Start the cron scheduler in its own goroutine, or no-op if already started. +func (c *Cron) Start() { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + return + } + c.running = true + go c.run() +} + +// Run the cron scheduler, or no-op if already running. +func (c *Cron) Run() { + c.runningMu.Lock() + if c.running { + c.runningMu.Unlock() + return + } + c.running = true + c.runningMu.Unlock() + c.run() +} + +// run the scheduler.. this is private just due to the need to synchronize +// access to the 'running' state variable. +func (c *Cron) run() { + c.logger.Info("start") + + // Figure out the next activation times for each entry. + now := c.now() + for _, entry := range c.entries { + entry.Next = entry.Schedule.Next(now) + c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next) + } + + for { + // Determine the next entry to run. + sort.Sort(byTime(c.entries)) + + var timer *time.Timer + if len(c.entries) == 0 || c.entries[0].Next.IsZero() { + // If there are no entries yet, just sleep - it still handles new entries + // and stop requests. + timer = time.NewTimer(100000 * time.Hour) + } else { + timer = time.NewTimer(c.entries[0].Next.Sub(now)) + } + + for { + select { + case now = <-timer.C: + now = now.In(c.location) + c.logger.Info("wake", "now", now) + + // Run every entry whose next time was less than now + for _, e := range c.entries { + if e.Next.After(now) || e.Next.IsZero() { + break + } + c.startJob(e.WrappedJob) + e.Prev = e.Next + e.Next = e.Schedule.Next(now) + c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next) + } + + case newEntry := <-c.add: + timer.Stop() + now = c.now() + newEntry.Next = newEntry.Schedule.Next(now) + c.entries = append(c.entries, newEntry) + c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next) + + case replyChan := <-c.snapshot: + replyChan <- c.entrySnapshot() + continue + + case <-c.stop: + timer.Stop() + c.logger.Info("stop") + return + + case id := <-c.remove: + timer.Stop() + now = c.now() + c.removeEntry(id) + c.logger.Info("removed", "entry", id) + } + + break + } + } +} + +// startJob runs the given job in a new goroutine. +func (c *Cron) startJob(j Job) { + c.jobWaiter.Add(1) + go func() { + defer c.jobWaiter.Done() + j.Run() + }() +} + +// now returns current time in c location +func (c *Cron) now() time.Time { + return time.Now().In(c.location) +} + +// Stop stops the cron scheduler if it is running; otherwise it does nothing. +// A context is returned so the caller can wait for running jobs to complete. +func (c *Cron) Stop() context.Context { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + c.stop <- struct{}{} + c.running = false + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + c.jobWaiter.Wait() + cancel() + }() + return ctx +} + +// entrySnapshot returns a copy of the current cron entry list. +func (c *Cron) entrySnapshot() []Entry { + var entries = make([]Entry, len(c.entries)) + for i, e := range c.entries { + entries[i] = *e + } + return entries +} + +func (c *Cron) removeEntry(id EntryID) { + var entries []*Entry + for _, e := range c.entries { + if e.ID != id { + entries = append(entries, e) + } + } + c.entries = entries +} diff --git a/vendor/github.com/robfig/cron/v3/doc.go b/vendor/github.com/robfig/cron/v3/doc.go new file mode 100644 index 00000000..fa5d08b4 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/doc.go @@ -0,0 +1,231 @@ +/* +Package cron implements a cron spec parser and job runner. + +Installation + +To download the specific tagged release, run: + + go get github.com/robfig/cron/v3@v3.0.0 + +Import it in your program as: + + import "github.com/robfig/cron/v3" + +It requires Go 1.11 or later due to usage of Go Modules. + +Usage + +Callers may register Funcs to be invoked on a given schedule. Cron will run +them in their own goroutines. + + c := cron.New() + c.AddFunc("30 * * * *", func() { fmt.Println("Every hour on the half hour") }) + c.AddFunc("30 3-6,20-23 * * *", func() { fmt.Println(".. in the range 3-6am, 8-11pm") }) + c.AddFunc("CRON_TZ=Asia/Tokyo 30 04 * * *", func() { fmt.Println("Runs at 04:30 Tokyo time every day") }) + c.AddFunc("@hourly", func() { fmt.Println("Every hour, starting an hour from now") }) + c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty, starting an hour thirty from now") }) + c.Start() + .. + // Funcs are invoked in their own goroutine, asynchronously. + ... + // Funcs may also be added to a running Cron + c.AddFunc("@daily", func() { fmt.Println("Every day") }) + .. + // Inspect the cron job entries' next and previous run times. + inspect(c.Entries()) + .. + c.Stop() // Stop the scheduler (does not stop any jobs already running). + +CRON Expression Format + +A cron expression represents a set of times, using 5 space-separated fields. + + Field name | Mandatory? | Allowed values | Allowed special characters + ---------- | ---------- | -------------- | -------------------------- + Minutes | Yes | 0-59 | * / , - + Hours | Yes | 0-23 | * / , - + Day of month | Yes | 1-31 | * / , - ? + Month | Yes | 1-12 or JAN-DEC | * / , - + Day of week | Yes | 0-6 or SUN-SAT | * / , - ? + +Month and Day-of-week field values are case insensitive. "SUN", "Sun", and +"sun" are equally accepted. + +The specific interpretation of the format is based on the Cron Wikipedia page: +https://en.wikipedia.org/wiki/Cron + +Alternative Formats + +Alternative Cron expression formats support other fields like seconds. You can +implement that by creating a custom Parser as follows. + + cron.New( + cron.WithParser( + cron.NewParser( + cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor))) + +Since adding Seconds is the most common modification to the standard cron spec, +cron provides a builtin function to do that, which is equivalent to the custom +parser you saw earlier, except that its seconds field is REQUIRED: + + cron.New(cron.WithSeconds()) + +That emulates Quartz, the most popular alternative Cron schedule format: +http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html + +Special Characters + +Asterisk ( * ) + +The asterisk indicates that the cron expression will match for all values of the +field; e.g., using an asterisk in the 5th field (month) would indicate every +month. + +Slash ( / ) + +Slashes are used to describe increments of ranges. For example 3-59/15 in the +1st field (minutes) would indicate the 3rd minute of the hour and every 15 +minutes thereafter. The form "*\/..." is equivalent to the form "first-last/...", +that is, an increment over the largest possible range of the field. The form +"N/..." is accepted as meaning "N-MAX/...", that is, starting at N, use the +increment until the end of that specific range. It does not wrap around. + +Comma ( , ) + +Commas are used to separate items of a list. For example, using "MON,WED,FRI" in +the 5th field (day of week) would mean Mondays, Wednesdays and Fridays. + +Hyphen ( - ) + +Hyphens are used to define ranges. For example, 9-17 would indicate every +hour between 9am and 5pm inclusive. + +Question mark ( ? ) + +Question mark may be used instead of '*' for leaving either day-of-month or +day-of-week blank. + +Predefined schedules + +You may use one of several pre-defined schedules in place of a cron expression. + + Entry | Description | Equivalent To + ----- | ----------- | ------------- + @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 1 1 * + @monthly | Run once a month, midnight, first of month | 0 0 1 * * + @weekly | Run once a week, midnight between Sat/Sun | 0 0 * * 0 + @daily (or @midnight) | Run once a day, midnight | 0 0 * * * + @hourly | Run once an hour, beginning of hour | 0 * * * * + +Intervals + +You may also schedule a job to execute at fixed intervals, starting at the time it's added +or cron is run. This is supported by formatting the cron spec like this: + + @every + +where "duration" is a string accepted by time.ParseDuration +(http://golang.org/pkg/time/#ParseDuration). + +For example, "@every 1h30m10s" would indicate a schedule that activates after +1 hour, 30 minutes, 10 seconds, and then every interval after that. + +Note: The interval does not take the job runtime into account. For example, +if a job takes 3 minutes to run, and it is scheduled to run every 5 minutes, +it will have only 2 minutes of idle time between each run. + +Time zones + +By default, all interpretation and scheduling is done in the machine's local +time zone (time.Local). You can specify a different time zone on construction: + + cron.New( + cron.WithLocation(time.UTC)) + +Individual cron schedules may also override the time zone they are to be +interpreted in by providing an additional space-separated field at the beginning +of the cron spec, of the form "CRON_TZ=Asia/Tokyo". + +For example: + + # Runs at 6am in time.Local + cron.New().AddFunc("0 6 * * ?", ...) + + # Runs at 6am in America/New_York + nyc, _ := time.LoadLocation("America/New_York") + c := cron.New(cron.WithLocation(nyc)) + c.AddFunc("0 6 * * ?", ...) + + # Runs at 6am in Asia/Tokyo + cron.New().AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) + + # Runs at 6am in Asia/Tokyo + c := cron.New(cron.WithLocation(nyc)) + c.SetLocation("America/New_York") + c.AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) + +The prefix "TZ=(TIME ZONE)" is also supported for legacy compatibility. + +Be aware that jobs scheduled during daylight-savings leap-ahead transitions will +not be run! + +Job Wrappers + +A Cron runner may be configured with a chain of job wrappers to add +cross-cutting functionality to all submitted jobs. For example, they may be used +to achieve the following effects: + + - Recover any panics from jobs (activated by default) + - Delay a job's execution if the previous run hasn't completed yet + - Skip a job's execution if the previous run hasn't completed yet + - Log each job's invocations + +Install wrappers for all jobs added to a cron using the `cron.WithChain` option: + + cron.New(cron.WithChain( + cron.SkipIfStillRunning(logger), + )) + +Install wrappers for individual jobs by explicitly wrapping them: + + job = cron.NewChain( + cron.SkipIfStillRunning(logger), + ).Then(job) + +Thread safety + +Since the Cron service runs concurrently with the calling code, some amount of +care must be taken to ensure proper synchronization. + +All cron methods are designed to be correctly synchronized as long as the caller +ensures that invocations have a clear happens-before ordering between them. + +Logging + +Cron defines a Logger interface that is a subset of the one defined in +github.com/go-logr/logr. It has two logging levels (Info and Error), and +parameters are key/value pairs. This makes it possible for cron logging to plug +into structured logging systems. An adapter, [Verbose]PrintfLogger, is provided +to wrap the standard library *log.Logger. + +For additional insight into Cron operations, verbose logging may be activated +which will record job runs, scheduling decisions, and added or removed jobs. +Activate it with a one-off logger as follows: + + cron.New( + cron.WithLogger( + cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))) + + +Implementation + +Cron entries are stored in an array, sorted by their next activation time. Cron +sleeps until the next job is due to be run. + +Upon waking: + - it runs each entry that is active on that second + - it calculates the next run times for the jobs that were run + - it re-sorts the array of entries by next activation time. + - it goes to sleep until the soonest job. +*/ +package cron diff --git a/vendor/github.com/robfig/cron/v3/logger.go b/vendor/github.com/robfig/cron/v3/logger.go new file mode 100644 index 00000000..b4efcc05 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/logger.go @@ -0,0 +1,86 @@ +package cron + +import ( + "io/ioutil" + "log" + "os" + "strings" + "time" +) + +// DefaultLogger is used by Cron if none is specified. +var DefaultLogger Logger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)) + +// DiscardLogger can be used by callers to discard all log messages. +var DiscardLogger Logger = PrintfLogger(log.New(ioutil.Discard, "", 0)) + +// Logger is the interface used in this package for logging, so that any backend +// can be plugged in. It is a subset of the github.com/go-logr/logr interface. +type Logger interface { + // Info logs routine messages about cron's operation. + Info(msg string, keysAndValues ...interface{}) + // Error logs an error condition. + Error(err error, msg string, keysAndValues ...interface{}) +} + +// PrintfLogger wraps a Printf-based logger (such as the standard library "log") +// into an implementation of the Logger interface which logs errors only. +func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { + return printfLogger{l, false} +} + +// VerbosePrintfLogger wraps a Printf-based logger (such as the standard library +// "log") into an implementation of the Logger interface which logs everything. +func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { + return printfLogger{l, true} +} + +type printfLogger struct { + logger interface{ Printf(string, ...interface{}) } + logInfo bool +} + +func (pl printfLogger) Info(msg string, keysAndValues ...interface{}) { + if pl.logInfo { + keysAndValues = formatTimes(keysAndValues) + pl.logger.Printf( + formatString(len(keysAndValues)), + append([]interface{}{msg}, keysAndValues...)...) + } +} + +func (pl printfLogger) Error(err error, msg string, keysAndValues ...interface{}) { + keysAndValues = formatTimes(keysAndValues) + pl.logger.Printf( + formatString(len(keysAndValues)+2), + append([]interface{}{msg, "error", err}, keysAndValues...)...) +} + +// formatString returns a logfmt-like format string for the number of +// key/values. +func formatString(numKeysAndValues int) string { + var sb strings.Builder + sb.WriteString("%s") + if numKeysAndValues > 0 { + sb.WriteString(", ") + } + for i := 0; i < numKeysAndValues/2; i++ { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString("%v=%v") + } + return sb.String() +} + +// formatTimes formats any time.Time values as RFC3339. +func formatTimes(keysAndValues []interface{}) []interface{} { + var formattedArgs []interface{} + for _, arg := range keysAndValues { + if t, ok := arg.(time.Time); ok { + arg = t.Format(time.RFC3339) + } + formattedArgs = append(formattedArgs, arg) + } + return formattedArgs +} diff --git a/vendor/github.com/robfig/cron/v3/option.go b/vendor/github.com/robfig/cron/v3/option.go new file mode 100644 index 00000000..09e4278e --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/option.go @@ -0,0 +1,45 @@ +package cron + +import ( + "time" +) + +// Option represents a modification to the default behavior of a Cron. +type Option func(*Cron) + +// WithLocation overrides the timezone of the cron instance. +func WithLocation(loc *time.Location) Option { + return func(c *Cron) { + c.location = loc + } +} + +// WithSeconds overrides the parser used for interpreting job schedules to +// include a seconds field as the first one. +func WithSeconds() Option { + return WithParser(NewParser( + Second | Minute | Hour | Dom | Month | Dow | Descriptor, + )) +} + +// WithParser overrides the parser used for interpreting job schedules. +func WithParser(p ScheduleParser) Option { + return func(c *Cron) { + c.parser = p + } +} + +// WithChain specifies Job wrappers to apply to all jobs added to this cron. +// Refer to the Chain* functions in this package for provided wrappers. +func WithChain(wrappers ...JobWrapper) Option { + return func(c *Cron) { + c.chain = NewChain(wrappers...) + } +} + +// WithLogger uses the provided logger. +func WithLogger(logger Logger) Option { + return func(c *Cron) { + c.logger = logger + } +} diff --git a/vendor/github.com/robfig/cron/v3/parser.go b/vendor/github.com/robfig/cron/v3/parser.go new file mode 100644 index 00000000..3cf8879f --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/parser.go @@ -0,0 +1,434 @@ +package cron + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// Configuration options for creating a parser. Most options specify which +// fields should be included, while others enable features. If a field is not +// included the parser will assume a default value. These options do not change +// the order fields are parse in. +type ParseOption int + +const ( + Second ParseOption = 1 << iota // Seconds field, default 0 + SecondOptional // Optional seconds field, default 0 + Minute // Minutes field, default 0 + Hour // Hours field, default 0 + Dom // Day of month field, default * + Month // Month field, default * + Dow // Day of week field, default * + DowOptional // Optional day of week field, default * + Descriptor // Allow descriptors such as @monthly, @weekly, etc. +) + +var places = []ParseOption{ + Second, + Minute, + Hour, + Dom, + Month, + Dow, +} + +var defaults = []string{ + "0", + "0", + "0", + "*", + "*", + "*", +} + +// A custom Parser that can be configured. +type Parser struct { + options ParseOption +} + +// NewParser creates a Parser with custom options. +// +// It panics if more than one Optional is given, since it would be impossible to +// correctly infer which optional is provided or missing in general. +// +// Examples +// +// // Standard parser without descriptors +// specParser := NewParser(Minute | Hour | Dom | Month | Dow) +// sched, err := specParser.Parse("0 0 15 */3 *") +// +// // Same as above, just excludes time fields +// subsParser := NewParser(Dom | Month | Dow) +// sched, err := specParser.Parse("15 */3 *") +// +// // Same as above, just makes Dow optional +// subsParser := NewParser(Dom | Month | DowOptional) +// sched, err := specParser.Parse("15 */3") +// +func NewParser(options ParseOption) Parser { + optionals := 0 + if options&DowOptional > 0 { + optionals++ + } + if options&SecondOptional > 0 { + optionals++ + } + if optionals > 1 { + panic("multiple optionals may not be configured") + } + return Parser{options} +} + +// Parse returns a new crontab schedule representing the given spec. +// It returns a descriptive error if the spec is not valid. +// It accepts crontab specs and features configured by NewParser. +func (p Parser) Parse(spec string) (Schedule, error) { + if len(spec) == 0 { + return nil, fmt.Errorf("empty spec string") + } + + // Extract timezone if present + var loc = time.Local + if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { + var err error + i := strings.Index(spec, " ") + eq := strings.Index(spec, "=") + if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil { + return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err) + } + spec = strings.TrimSpace(spec[i:]) + } + + // Handle named schedules (descriptors), if configured + if strings.HasPrefix(spec, "@") { + if p.options&Descriptor == 0 { + return nil, fmt.Errorf("parser does not accept descriptors: %v", spec) + } + return parseDescriptor(spec, loc) + } + + // Split on whitespace. + fields := strings.Fields(spec) + + // Validate & fill in any omitted or optional fields + var err error + fields, err = normalizeFields(fields, p.options) + if err != nil { + return nil, err + } + + field := func(field string, r bounds) uint64 { + if err != nil { + return 0 + } + var bits uint64 + bits, err = getField(field, r) + return bits + } + + var ( + second = field(fields[0], seconds) + minute = field(fields[1], minutes) + hour = field(fields[2], hours) + dayofmonth = field(fields[3], dom) + month = field(fields[4], months) + dayofweek = field(fields[5], dow) + ) + if err != nil { + return nil, err + } + + return &SpecSchedule{ + Second: second, + Minute: minute, + Hour: hour, + Dom: dayofmonth, + Month: month, + Dow: dayofweek, + Location: loc, + }, nil +} + +// normalizeFields takes a subset set of the time fields and returns the full set +// with defaults (zeroes) populated for unset fields. +// +// As part of performing this function, it also validates that the provided +// fields are compatible with the configured options. +func normalizeFields(fields []string, options ParseOption) ([]string, error) { + // Validate optionals & add their field to options + optionals := 0 + if options&SecondOptional > 0 { + options |= Second + optionals++ + } + if options&DowOptional > 0 { + options |= Dow + optionals++ + } + if optionals > 1 { + return nil, fmt.Errorf("multiple optionals may not be configured") + } + + // Figure out how many fields we need + max := 0 + for _, place := range places { + if options&place > 0 { + max++ + } + } + min := max - optionals + + // Validate number of fields + if count := len(fields); count < min || count > max { + if min == max { + return nil, fmt.Errorf("expected exactly %d fields, found %d: %s", min, count, fields) + } + return nil, fmt.Errorf("expected %d to %d fields, found %d: %s", min, max, count, fields) + } + + // Populate the optional field if not provided + if min < max && len(fields) == min { + switch { + case options&DowOptional > 0: + fields = append(fields, defaults[5]) // TODO: improve access to default + case options&SecondOptional > 0: + fields = append([]string{defaults[0]}, fields...) + default: + return nil, fmt.Errorf("unknown optional field") + } + } + + // Populate all fields not part of options with their defaults + n := 0 + expandedFields := make([]string, len(places)) + copy(expandedFields, defaults) + for i, place := range places { + if options&place > 0 { + expandedFields[i] = fields[n] + n++ + } + } + return expandedFields, nil +} + +var standardParser = NewParser( + Minute | Hour | Dom | Month | Dow | Descriptor, +) + +// ParseStandard returns a new crontab schedule representing the given +// standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries +// representing: minute, hour, day of month, month and day of week, in that +// order. It returns a descriptive error if the spec is not valid. +// +// It accepts +// - Standard crontab specs, e.g. "* * * * ?" +// - Descriptors, e.g. "@midnight", "@every 1h30m" +func ParseStandard(standardSpec string) (Schedule, error) { + return standardParser.Parse(standardSpec) +} + +// getField returns an Int with the bits set representing all of the times that +// the field represents or error parsing field value. A "field" is a comma-separated +// list of "ranges". +func getField(field string, r bounds) (uint64, error) { + var bits uint64 + ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) + for _, expr := range ranges { + bit, err := getRange(expr, r) + if err != nil { + return bits, err + } + bits |= bit + } + return bits, nil +} + +// getRange returns the bits indicated by the given expression: +// number | number "-" number [ "/" number ] +// or error parsing range. +func getRange(expr string, r bounds) (uint64, error) { + var ( + start, end, step uint + rangeAndStep = strings.Split(expr, "/") + lowAndHigh = strings.Split(rangeAndStep[0], "-") + singleDigit = len(lowAndHigh) == 1 + err error + ) + + var extra uint64 + if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { + start = r.min + end = r.max + extra = starBit + } else { + start, err = parseIntOrName(lowAndHigh[0], r.names) + if err != nil { + return 0, err + } + switch len(lowAndHigh) { + case 1: + end = start + case 2: + end, err = parseIntOrName(lowAndHigh[1], r.names) + if err != nil { + return 0, err + } + default: + return 0, fmt.Errorf("too many hyphens: %s", expr) + } + } + + switch len(rangeAndStep) { + case 1: + step = 1 + case 2: + step, err = mustParseInt(rangeAndStep[1]) + if err != nil { + return 0, err + } + + // Special handling: "N/step" means "N-max/step". + if singleDigit { + end = r.max + } + if step > 1 { + extra = 0 + } + default: + return 0, fmt.Errorf("too many slashes: %s", expr) + } + + if start < r.min { + return 0, fmt.Errorf("beginning of range (%d) below minimum (%d): %s", start, r.min, expr) + } + if end > r.max { + return 0, fmt.Errorf("end of range (%d) above maximum (%d): %s", end, r.max, expr) + } + if start > end { + return 0, fmt.Errorf("beginning of range (%d) beyond end of range (%d): %s", start, end, expr) + } + if step == 0 { + return 0, fmt.Errorf("step of range should be a positive number: %s", expr) + } + + return getBits(start, end, step) | extra, nil +} + +// parseIntOrName returns the (possibly-named) integer contained in expr. +func parseIntOrName(expr string, names map[string]uint) (uint, error) { + if names != nil { + if namedInt, ok := names[strings.ToLower(expr)]; ok { + return namedInt, nil + } + } + return mustParseInt(expr) +} + +// mustParseInt parses the given expression as an int or returns an error. +func mustParseInt(expr string) (uint, error) { + num, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("failed to parse int from %s: %s", expr, err) + } + if num < 0 { + return 0, fmt.Errorf("negative number (%d) not allowed: %s", num, expr) + } + + return uint(num), nil +} + +// getBits sets all bits in the range [min, max], modulo the given step size. +func getBits(min, max, step uint) uint64 { + var bits uint64 + + // If step is 1, use shifts. + if step == 1 { + return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) + } + + // Else, use a simple loop. + for i := min; i <= max; i += step { + bits |= 1 << i + } + return bits +} + +// all returns all bits within the given bounds. (plus the star bit) +func all(r bounds) uint64 { + return getBits(r.min, r.max, 1) | starBit +} + +// parseDescriptor returns a predefined schedule for the expression, or error if none matches. +func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { + switch descriptor { + case "@yearly", "@annually": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: 1 << months.min, + Dow: all(dow), + Location: loc, + }, nil + + case "@monthly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + case "@weekly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: 1 << dow.min, + Location: loc, + }, nil + + case "@daily", "@midnight": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + case "@hourly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: all(hours), + Dom: all(dom), + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + } + + const every = "@every " + if strings.HasPrefix(descriptor, every) { + duration, err := time.ParseDuration(descriptor[len(every):]) + if err != nil { + return nil, fmt.Errorf("failed to parse duration %s: %s", descriptor, err) + } + return Every(duration), nil + } + + return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor) +} diff --git a/vendor/github.com/robfig/cron/v3/spec.go b/vendor/github.com/robfig/cron/v3/spec.go new file mode 100644 index 00000000..fa1e241e --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/spec.go @@ -0,0 +1,188 @@ +package cron + +import "time" + +// SpecSchedule specifies a duty cycle (to the second granularity), based on a +// traditional crontab specification. It is computed initially and stored as bit sets. +type SpecSchedule struct { + Second, Minute, Hour, Dom, Month, Dow uint64 + + // Override location for this schedule. + Location *time.Location +} + +// bounds provides a range of acceptable values (plus a map of name to value). +type bounds struct { + min, max uint + names map[string]uint +} + +// The bounds for each field. +var ( + seconds = bounds{0, 59, nil} + minutes = bounds{0, 59, nil} + hours = bounds{0, 23, nil} + dom = bounds{1, 31, nil} + months = bounds{1, 12, map[string]uint{ + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + }} + dow = bounds{0, 6, map[string]uint{ + "sun": 0, + "mon": 1, + "tue": 2, + "wed": 3, + "thu": 4, + "fri": 5, + "sat": 6, + }} +) + +const ( + // Set the top bit if a star was included in the expression. + starBit = 1 << 63 +) + +// Next returns the next time this schedule is activated, greater than the given +// time. If no time can be found to satisfy the schedule, return the zero time. +func (s *SpecSchedule) Next(t time.Time) time.Time { + // General approach + // + // For Month, Day, Hour, Minute, Second: + // Check if the time value matches. If yes, continue to the next field. + // If the field doesn't match the schedule, then increment the field until it matches. + // While incrementing the field, a wrap-around brings it back to the beginning + // of the field list (since it is necessary to re-verify previous field + // values) + + // Convert the given time into the schedule's timezone, if one is specified. + // Save the original timezone so we can convert back after we find a time. + // Note that schedules without a time zone specified (time.Local) are treated + // as local to the time provided. + origLocation := t.Location() + loc := s.Location + if loc == time.Local { + loc = t.Location() + } + if s.Location != time.Local { + t = t.In(s.Location) + } + + // Start at the earliest possible time (the upcoming second). + t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) + + // This flag indicates whether a field has been incremented. + added := false + + // If no time is found within five years, return zero. + yearLimit := t.Year() + 5 + +WRAP: + if t.Year() > yearLimit { + return time.Time{} + } + + // Find the first applicable month. + // If it's this month, then do nothing. + for 1< 12 { + t = t.Add(time.Duration(24-t.Hour()) * time.Hour) + } else { + t = t.Add(time.Duration(-t.Hour()) * time.Hour) + } + } + + if t.Day() == 1 { + goto WRAP + } + } + + for 1< 0 + dowMatch bool = 1< 0 + ) + if s.Dom&starBit > 0 || s.Dow&starBit > 0 { + return domMatch && dowMatch + } + return domMatch || dowMatch +} diff --git a/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go b/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go new file mode 100644 index 00000000..82a473bb --- /dev/null +++ b/vendor/k8s.io/apimachinery/pkg/util/rand/rand.go @@ -0,0 +1,127 @@ +/* +Copyright 2015 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 rand provides utilities related to randomization. +package rand + +import ( + "math/rand" + "sync" + "time" +) + +var rng = struct { + sync.Mutex + rand *rand.Rand +}{ + rand: rand.New(rand.NewSource(time.Now().UnixNano())), +} + +// Int returns a non-negative pseudo-random int. +func Int() int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Int() +} + +// Intn generates an integer in range [0,max). +// By design this should panic if input is invalid, <= 0. +func Intn(max int) int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Intn(max) +} + +// IntnRange generates an integer in range [min,max). +// By design this should panic if input is invalid, <= 0. +func IntnRange(min, max int) int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Intn(max-min) + min +} + +// IntnRange generates an int64 integer in range [min,max). +// By design this should panic if input is invalid, <= 0. +func Int63nRange(min, max int64) int64 { + rng.Lock() + defer rng.Unlock() + return rng.rand.Int63n(max-min) + min +} + +// Seed seeds the rng with the provided seed. +func Seed(seed int64) { + rng.Lock() + defer rng.Unlock() + + rng.rand = rand.New(rand.NewSource(seed)) +} + +// Perm returns, as a slice of n ints, a pseudo-random permutation of the integers [0,n) +// from the default Source. +func Perm(n int) []int { + rng.Lock() + defer rng.Unlock() + return rng.rand.Perm(n) +} + +const ( + // We omit vowels from the set of available characters to reduce the chances + // of "bad words" being formed. + alphanums = "bcdfghjklmnpqrstvwxz2456789" + // No. of bits required to index into alphanums string. + alphanumsIdxBits = 5 + // Mask used to extract last alphanumsIdxBits of an int. + alphanumsIdxMask = 1<>= alphanumsIdxBits + remaining-- + } + return string(b) +} + +// SafeEncodeString encodes s using the same characters as rand.String. This reduces the chances of bad words and +// ensures that strings generated from hash functions appear consistent throughout the API. +func SafeEncodeString(s string) string { + r := make([]byte, len(s)) + for i, b := range []rune(s) { + r[i] = alphanums[(int(b) % len(alphanums))] + } + return string(r) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index dd8c2919..c6c6e90d 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -200,6 +200,7 @@ github.com/prometheus/procfs/internal/fs github.com/prometheus/procfs/internal/util # github.com/robfig/cron/v3 v3.0.1 ## explicit; go 1.12 +github.com/robfig/cron/v3 # github.com/sirupsen/logrus v1.9.3 ## explicit; go 1.13 github.com/sirupsen/logrus @@ -452,6 +453,7 @@ k8s.io/apimachinery/pkg/util/managedfields/internal k8s.io/apimachinery/pkg/util/mergepatch k8s.io/apimachinery/pkg/util/naming k8s.io/apimachinery/pkg/util/net +k8s.io/apimachinery/pkg/util/rand k8s.io/apimachinery/pkg/util/runtime k8s.io/apimachinery/pkg/util/sets k8s.io/apimachinery/pkg/util/strategicpatch @@ -789,6 +791,8 @@ sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics sigs.k8s.io/controller-runtime/pkg/client sigs.k8s.io/controller-runtime/pkg/client/apiutil sigs.k8s.io/controller-runtime/pkg/client/config +sigs.k8s.io/controller-runtime/pkg/client/fake +sigs.k8s.io/controller-runtime/pkg/client/interceptor sigs.k8s.io/controller-runtime/pkg/cluster sigs.k8s.io/controller-runtime/pkg/config sigs.k8s.io/controller-runtime/pkg/controller @@ -806,6 +810,7 @@ sigs.k8s.io/controller-runtime/pkg/internal/flock sigs.k8s.io/controller-runtime/pkg/internal/httpserver sigs.k8s.io/controller-runtime/pkg/internal/log sigs.k8s.io/controller-runtime/pkg/internal/metrics +sigs.k8s.io/controller-runtime/pkg/internal/objectutil sigs.k8s.io/controller-runtime/pkg/internal/recorder sigs.k8s.io/controller-runtime/pkg/internal/source sigs.k8s.io/controller-runtime/pkg/internal/syncs diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go new file mode 100644 index 00000000..16e2cba5 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go @@ -0,0 +1,1602 @@ +/* +Copyright 2018 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 fake + +import ( + "bytes" + "context" + "errors" + "fmt" + "reflect" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" + + /* + Stick with gopkg.in/evanphx/json-patch.v4 here to match + upstream Kubernetes code and avoid breaking changes introduced in v5. + - Kubernetes itself remains on json-patch v4 to avoid compatibility issues + tied to v5’s stricter RFC6902 compliance. + - The fake client code is adapted from client-go’s testing fixture, which also + relies on json-patch v4. + See: + https://github.com/kubernetes/kubernetes/pull/91622 (discussion of why K8s + stays on v4) + https://github.com/kubernetes/kubernetes/pull/120326 (v5.6.0+incompatible + missing a critical fix) + */ + jsonpatch "gopkg.in/evanphx/json-patch.v4" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + utilrand "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/testing" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/internal/field/selector" + "sigs.k8s.io/controller-runtime/pkg/internal/objectutil" +) + +type versionedTracker struct { + testing.ObjectTracker + scheme *runtime.Scheme + withStatusSubresource sets.Set[schema.GroupVersionKind] +} + +type fakeClient struct { + // trackerWriteLock must be acquired before writing to + // the tracker or performing reads that affect a following + // write. + trackerWriteLock sync.Mutex + tracker versionedTracker + + schemeLock sync.RWMutex + scheme *runtime.Scheme + + restMapper meta.RESTMapper + withStatusSubresource sets.Set[schema.GroupVersionKind] + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc + // indexesLock must be held when accessing indexes. + indexesLock sync.RWMutex +} + +var _ client.WithWatch = &fakeClient{} + +const ( + maxNameLength = 63 + randomLength = 5 + maxGeneratedNameLength = maxNameLength - randomLength + + subResourceScale = "scale" +) + +// NewFakeClient creates a new fake client for testing. +// You can choose to initialize it with a slice of runtime.Object. +func NewFakeClient(initObjs ...runtime.Object) client.WithWatch { + return NewClientBuilder().WithRuntimeObjects(initObjs...).Build() +} + +// NewClientBuilder returns a new builder to create a fake client. +func NewClientBuilder() *ClientBuilder { + return &ClientBuilder{} +} + +// ClientBuilder builds a fake client. +type ClientBuilder struct { + scheme *runtime.Scheme + restMapper meta.RESTMapper + initObject []client.Object + initLists []client.ObjectList + initRuntimeObjects []runtime.Object + withStatusSubresource []client.Object + objectTracker testing.ObjectTracker + interceptorFuncs *interceptor.Funcs + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc +} + +// WithScheme sets this builder's internal scheme. +// If not set, defaults to client-go's global scheme.Scheme. +func (f *ClientBuilder) WithScheme(scheme *runtime.Scheme) *ClientBuilder { + f.scheme = scheme + return f +} + +// WithRESTMapper sets this builder's restMapper. +// The restMapper is directly set as mapper in the Client. This can be used for example +// with a meta.DefaultRESTMapper to provide a static rest mapping. +// If not set, defaults to an empty meta.DefaultRESTMapper. +func (f *ClientBuilder) WithRESTMapper(restMapper meta.RESTMapper) *ClientBuilder { + f.restMapper = restMapper + return f +} + +// WithObjects can be optionally used to initialize this fake client with client.Object(s). +func (f *ClientBuilder) WithObjects(initObjs ...client.Object) *ClientBuilder { + f.initObject = append(f.initObject, initObjs...) + return f +} + +// WithLists can be optionally used to initialize this fake client with client.ObjectList(s). +func (f *ClientBuilder) WithLists(initLists ...client.ObjectList) *ClientBuilder { + f.initLists = append(f.initLists, initLists...) + return f +} + +// WithRuntimeObjects can be optionally used to initialize this fake client with runtime.Object(s). +func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *ClientBuilder { + f.initRuntimeObjects = append(f.initRuntimeObjects, initRuntimeObjs...) + return f +} + +// WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker. +func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder { + f.objectTracker = ot + return f +} + +// WithIndex can be optionally used to register an index with name `field` and indexer `extractValue` +// for API objects of the same GroupVersionKind (GVK) as `obj` in the fake client. +// It can be invoked multiple times, both with objects of the same GVK or different ones. +// Invoking WithIndex twice with the same `field` and GVK (via `obj`) arguments will panic. +// WithIndex retrieves the GVK of `obj` using the scheme registered via WithScheme if +// WithScheme was previously invoked, the default scheme otherwise. +func (f *ClientBuilder) WithIndex(obj runtime.Object, field string, extractValue client.IndexerFunc) *ClientBuilder { + objScheme := f.scheme + if objScheme == nil { + objScheme = scheme.Scheme + } + + gvk, err := apiutil.GVKForObject(obj, objScheme) + if err != nil { + panic(err) + } + + // If this is the first index being registered, we initialize the map storing all the indexes. + if f.indexes == nil { + f.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc) + } + + // If this is the first index being registered for the GroupVersionKind of `obj`, we initialize + // the map storing the indexes for that GroupVersionKind. + if f.indexes[gvk] == nil { + f.indexes[gvk] = make(map[string]client.IndexerFunc) + } + + if _, fieldAlreadyIndexed := f.indexes[gvk][field]; fieldAlreadyIndexed { + panic(fmt.Errorf("indexer conflict: field %s for GroupVersionKind %v is already indexed", + field, gvk)) + } + + f.indexes[gvk][field] = extractValue + + return f +} + +// WithStatusSubresource configures the passed object with a status subresource, which means +// calls to Update and Patch will not alter its status. +func (f *ClientBuilder) WithStatusSubresource(o ...client.Object) *ClientBuilder { + f.withStatusSubresource = append(f.withStatusSubresource, o...) + return f +} + +// WithInterceptorFuncs configures the client methods to be intercepted using the provided interceptor.Funcs. +func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs) *ClientBuilder { + f.interceptorFuncs = &interceptorFuncs + return f +} + +// Build builds and returns a new fake client. +func (f *ClientBuilder) Build() client.WithWatch { + if f.scheme == nil { + f.scheme = scheme.Scheme + } + if f.restMapper == nil { + f.restMapper = meta.NewDefaultRESTMapper([]schema.GroupVersion{}) + } + + var tracker versionedTracker + + withStatusSubResource := sets.New(inTreeResourcesWithStatus()...) + for _, o := range f.withStatusSubresource { + gvk, err := apiutil.GVKForObject(o, f.scheme) + if err != nil { + panic(fmt.Errorf("failed to get gvk for object %T: %w", withStatusSubResource, err)) + } + withStatusSubResource.Insert(gvk) + } + + if f.objectTracker == nil { + tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource} + } else { + tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource} + } + + for _, obj := range f.initObject { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add object %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initLists { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add list %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initRuntimeObjects { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add runtime object %v to fake client: %w", obj, err)) + } + } + + var result client.WithWatch = &fakeClient{ + tracker: tracker, + scheme: f.scheme, + restMapper: f.restMapper, + indexes: f.indexes, + withStatusSubresource: withStatusSubResource, + } + + if f.interceptorFuncs != nil { + result = interceptor.NewClient(result, *f.interceptorFuncs) + } + + return result +} + +const trackerAddResourceVersion = "999" + +func (t versionedTracker) Add(obj runtime.Object) error { + var objects []runtime.Object + if meta.IsListType(obj) { + var err error + objects, err = meta.ExtractList(obj) + if err != nil { + return err + } + } else { + objects = []runtime.Object{obj} + } + for _, obj := range objects { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 { + return fmt.Errorf("refusing to create obj %s with metadata.deletionTimestamp but no finalizers", accessor.GetName()) + } + if accessor.GetResourceVersion() == "" { + // We use a "magic" value of 999 here because this field + // is parsed as uint and and 0 is already used in Update. + // As we can't go lower, go very high instead so this can + // be recognized + accessor.SetResourceVersion(trackerAddResourceVersion) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.ObjectTracker.Add(obj); err != nil { + return err + } + } + + return nil +} + +func (t versionedTracker) Create(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.CreateOptions) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetName() == "" { + return apierrors.NewInvalid( + obj.GetObjectKind().GroupVersionKind().GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + if accessor.GetResourceVersion() != "" { + return apierrors.NewBadRequest("resourceVersion can not be set for Create requests") + } + accessor.SetResourceVersion("1") + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.ObjectTracker.Create(gvr, obj, ns, opts...); err != nil { + accessor.SetResourceVersion("") + return err + } + + return nil +} + +// convertFromUnstructuredIfNecessary will convert runtime.Unstructured for a GVK that is recognized +// by the schema into the whatever the schema produces with New() for said GVK. +// This is required because the tracker unconditionally saves on manipulations, but its List() implementation +// tries to assign whatever it finds into a ListType it gets from schema.New() - Thus we have to ensure +// we save as the very same type, otherwise subsequent List requests will fail. +func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (runtime.Object, error) { + u, isUnstructured := o.(runtime.Unstructured) + if !isUnstructured { + return o, nil + } + gvk := o.GetObjectKind().GroupVersionKind() + if !s.Recognizes(gvk) { + return o, nil + } + + typed, err := s.New(gvk) + if err != nil { + return nil, fmt.Errorf("scheme recognizes %s but failed to produce an object for it: %w", gvk, err) + } + + unstructuredSerialized, err := json.Marshal(u) + if err != nil { + return nil, fmt.Errorf("failed to serialize %T: %w", unstructuredSerialized, err) + } + if err := json.Unmarshal(unstructuredSerialized, typed); err != nil { + return nil, fmt.Errorf("failed to unmarshal the content of %T into %T: %w", u, typed, err) + } + + return typed, nil +} + +func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.UpdateOptions) error { + updateOpts, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + return t.update(gvr, obj, ns, false, false, updateOpts) +} + +func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error { + obj, err := t.updateObject(gvr, obj, ns, isStatus, deleting, opts.DryRun) + if err != nil { + return err + } + if obj == nil { + return nil + } + + return t.ObjectTracker.Update(gvr, obj, ns, opts) +} + +func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + // We apply patches using a client-go reaction that ends up calling the trackers Patch. As we can't change + // that reaction, we use the callstack to figure out if this originated from the status client. + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + obj, err = t.updateObject(gvr, obj, ns, isStatus, false, patchOptions.DryRun) + if err != nil { + return err + } + if obj == nil { + return nil + } + + return t.ObjectTracker.Patch(gvr, obj, ns, patchOptions) +} + +func (t versionedTracker) updateObject(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, dryRun []string) (runtime.Object, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, fmt.Errorf("failed to get accessor for object: %w", err) + } + + if accessor.GetName() == "" { + return nil, apierrors.NewInvalid( + obj.GetObjectKind().GroupVersionKind().GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return nil, err + } + + oldObject, err := t.ObjectTracker.Get(gvr, ns, accessor.GetName()) + if err != nil { + // If the resource is not found and the resource allows create on update, issue a + // create instead. + if apierrors.IsNotFound(err) && allowsCreateOnUpdate(gvk) { + return nil, t.Create(gvr, obj, ns) + } + return nil, err + } + + if t.withStatusSubresource.Has(gvk) { + if isStatus { // copy everything but status and metadata.ResourceVersion from original object + if err := copyStatusFrom(obj, oldObject); err != nil { + return nil, fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err) + } + passedRV := accessor.GetResourceVersion() + if err := copyFrom(oldObject, obj); err != nil { + return nil, fmt.Errorf("failed to restore non-status fields: %w", err) + } + accessor.SetResourceVersion(passedRV) + } else { // copy status from original object + if err := copyStatusFrom(oldObject, obj); err != nil { + return nil, fmt.Errorf("failed to copy the status for object with status subresource: %w", err) + } + } + } else if isStatus { + return nil, apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName()) + } + + oldAccessor, err := meta.Accessor(oldObject) + if err != nil { + return nil, err + } + + // If the new object does not have the resource version set and it allows unconditional update, + // default it to the resource version of the existing resource + if accessor.GetResourceVersion() == "" { + switch { + case allowsUnconditionalUpdate(gvk): + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + // This is needed because if the patch explicitly sets the RV to null, the client-go reaction we use + // to apply it and whose output we process here will have it unset. It is not clear why the Kubernetes + // apiserver accepts such a patch, but it does so we just copy that behavior. + // Kubernetes apiserver behavior can be checked like this: + // `kubectl patch configmap foo --patch '{"metadata":{"annotations":{"foo":"bar"},"resourceVersion":null}}' -v=9` + case bytes. + Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Patch")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Patch" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + } + } + + if accessor.GetResourceVersion() != oldAccessor.GetResourceVersion() { + return nil, apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), errors.New("object was modified")) + } + if oldAccessor.GetResourceVersion() == "" { + oldAccessor.SetResourceVersion("0") + } + intResourceVersion, err := strconv.ParseUint(oldAccessor.GetResourceVersion(), 10, 64) + if err != nil { + return nil, fmt.Errorf("can not convert resourceVersion %q to int: %w", oldAccessor.GetResourceVersion(), err) + } + intResourceVersion++ + accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10)) + + if !deleting && !deletionTimestampEqual(accessor, oldAccessor) { + return nil, fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName()) + } + + if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 { + return nil, t.ObjectTracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName(), metav1.DeleteOptions{DryRun: dryRun}) + } + return convertFromUnstructuredIfNecessary(t.scheme, obj) +} + +func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + o, err := c.tracker.Get(gvr, key.Namespace, key.Name) + if err != nil { + return err + } + + _, isUnstructured := obj.(runtime.Unstructured) + _, isPartialObject := obj.(*metav1.PartialObjectMetadata) + + if isUnstructured || isPartialObject { + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(gvk.Kind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + return json.Unmarshal(j, obj) +} + +func (c *fakeClient) Watch(ctx context.Context, list client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + gvk, err := apiutil.GVKForObject(list, c.scheme) + if err != nil { + return nil, err + } + + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return c.tracker.Watch(gvr, listOpts.Namespace) +} + +func (c *fakeClient) List(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + originalKind := gvk.Kind + + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + + if _, isUnstructuredList := obj.(runtime.Unstructured); isUnstructuredList && !c.scheme.Recognizes(gvk) { + // We need to register the ListKind with UnstructuredList: + // https://github.com/kubernetes/kubernetes/blob/7b2776b89fb1be28d4e9203bdeec079be903c103/staging/src/k8s.io/client-go/dynamic/fake/simple.go#L44-L51 + c.schemeLock.RUnlock() + c.schemeLock.Lock() + c.scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind(gvk.Kind+"List"), &unstructured.UnstructuredList{}) + c.schemeLock.Unlock() + c.schemeLock.RLock() + } + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, listOpts.Namespace) + if err != nil { + return err + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(originalKind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + objCopy := obj.DeepCopyObject().(client.ObjectList) + if err := json.Unmarshal(j, objCopy); err != nil { + return err + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(obj) + if err != nil { + return err + } + ta.SetKind(originalKind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + objs, err := meta.ExtractList(objCopy) + if err != nil { + return err + } + + if listOpts.LabelSelector == nil && listOpts.FieldSelector == nil { + return meta.SetList(obj, objs) + } + + // If we're here, either a label or field selector are specified (or both), so before we return + // the list we must filter it. If both selectors are set, they are ANDed. + filteredList, err := c.filterList(objs, gvk, listOpts.LabelSelector, listOpts.FieldSelector) + if err != nil { + return err + } + + return meta.SetList(obj, filteredList) +} + +func (c *fakeClient) filterList(list []runtime.Object, gvk schema.GroupVersionKind, ls labels.Selector, fs fields.Selector) ([]runtime.Object, error) { + // Filter the objects with the label selector + filteredList := list + if ls != nil { + objsFilteredByLabel, err := objectutil.FilterWithLabels(list, ls) + if err != nil { + return nil, err + } + filteredList = objsFilteredByLabel + } + + // Filter the result of the previous pass with the field selector + if fs != nil { + objsFilteredByField, err := c.filterWithFields(filteredList, gvk, fs) + if err != nil { + return nil, err + } + filteredList = objsFilteredByField + } + + return filteredList, nil +} + +func (c *fakeClient) filterWithFields(list []runtime.Object, gvk schema.GroupVersionKind, fs fields.Selector) ([]runtime.Object, error) { + requiresExact := selector.RequiresExactMatch(fs) + if !requiresExact { + return nil, fmt.Errorf(`field selector %s is not in one of the two supported forms "key==val" or "key=val"`, fs) + } + + c.indexesLock.RLock() + defer c.indexesLock.RUnlock() + // Field selection is mimicked via indexes, so there's no sane answer this function can give + // if there are no indexes registered for the GroupVersionKind of the objects in the list. + indexes := c.indexes[gvk] + for _, req := range fs.Requirements() { + if len(indexes) == 0 || indexes[req.Field] == nil { + return nil, fmt.Errorf("List on GroupVersionKind %v specifies selector on field %s, but no "+ + "index with name %s has been registered for GroupVersionKind %v", gvk, req.Field, req.Field, gvk) + } + } + + filteredList := make([]runtime.Object, 0, len(list)) + for _, obj := range list { + matches := true + for _, req := range fs.Requirements() { + indexExtractor := indexes[req.Field] + if !c.objMatchesFieldSelector(obj, indexExtractor, req.Value) { + matches = false + break + } + } + if matches { + filteredList = append(filteredList, obj) + } + } + return filteredList, nil +} + +func (c *fakeClient) objMatchesFieldSelector(o runtime.Object, extractIndex client.IndexerFunc, val string) bool { + obj, isClientObject := o.(client.Object) + if !isClientObject { + panic(fmt.Errorf("expected object %v to be of type client.Object, but it's not", o)) + } + + for _, extractedVal := range extractIndex(obj) { + if extractedVal == val { + return true + } + } + + return false +} + +func (c *fakeClient) Scheme() *runtime.Scheme { + return c.scheme +} + +func (c *fakeClient) RESTMapper() meta.RESTMapper { + return c.restMapper +} + +// GroupVersionKindFor returns the GroupVersionKind for the given object. +func (c *fakeClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return apiutil.GVKForObject(obj, c.scheme) +} + +// IsObjectNamespaced returns true if the GroupVersionKind of the object is namespaced. +func (c *fakeClient) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return apiutil.IsObjectNamespaced(obj, c.scheme, c.restMapper) +} + +func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + createOptions := &client.CreateOptions{} + createOptions.ApplyOptions(opts) + + for _, dryRunOpt := range createOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + if accessor.GetName() == "" && accessor.GetGenerateName() != "" { + base := accessor.GetGenerateName() + if len(base) > maxGeneratedNameLength { + base = base[:maxGeneratedNameLength] + } + accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength))) + } + // Ignore attempts to set deletion timestamp + if !accessor.GetDeletionTimestamp().IsZero() { + accessor.SetDeletionTimestamp(nil) + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + return c.tracker.Create(gvr, obj, accessor.GetNamespace()) +} + +func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + delOptions := client.DeleteOptions{} + delOptions.ApplyOptions(opts) + + for _, dryRunOpt := range delOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + // Check the ResourceVersion if that Precondition was specified. + if delOptions.Preconditions != nil && delOptions.Preconditions.ResourceVersion != nil { + name := accessor.GetName() + dbObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), name) + if err != nil { + return err + } + oldAccessor, err := meta.Accessor(dbObj) + if err != nil { + return err + } + actualRV := oldAccessor.GetResourceVersion() + expectRV := *delOptions.Preconditions.ResourceVersion + if actualRV != expectRV { + msg := fmt.Sprintf( + "the ResourceVersion in the precondition (%s) does not match the ResourceVersion in record (%s). "+ + "The object might have been modified", + expectRV, actualRV) + return apierrors.NewConflict(gvr.GroupResource(), name, errors.New(msg)) + } + } + + return c.deleteObjectLocked(gvr, accessor) +} + +func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + dcOptions := client.DeleteAllOfOptions{} + dcOptions.ApplyOptions(opts) + + for _, dryRunOpt := range dcOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, dcOptions.Namespace) + if err != nil { + return err + } + + objs, err := meta.ExtractList(o) + if err != nil { + return err + } + filteredObjs, err := objectutil.FilterWithLabels(objs, dcOptions.LabelSelector) + if err != nil { + return err + } + for _, o := range filteredObjs { + accessor, err := meta.Accessor(o) + if err != nil { + return err + } + err = c.deleteObjectLocked(gvr, accessor) + if err != nil { + return err + } + } + return nil +} + +func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return c.update(obj, false, opts...) +} + +func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.UpdateOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + updateOptions := &client.UpdateOptions{} + updateOptions.ApplyOptions(opts) + + for _, dryRunOpt := range updateOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus, false, *updateOptions.AsUpdateOptions()) +} + +func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return c.patch(obj, patch, opts...) +} + +func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + patchOptions := &client.PatchOptions{} + patchOptions.ApplyOptions(opts) + + for _, dryRunOpt := range patchOptions.DryRun { + if dryRunOpt == metav1.DryRunAll { + return nil + } + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + data, err := patch.Data(obj) + if err != nil { + return err + } + + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err != nil { + return err + } + oldAccessor, err := meta.Accessor(oldObj) + if err != nil { + return err + } + + // Apply patch without updating object. + // To remain in accordance with the behavior of k8s api behavior, + // a patch must not allow for changes to the deletionTimestamp of an object. + // The reaction() function applies the patch to the object and calls Update(), + // whereas dryPatch() replicates this behavior but skips the call to Update(). + // This ensures that the patch may be rejected if a deletionTimestamp is modified, prior + // to updating the object. + action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data) + o, err := dryPatch(action, c.tracker) + if err != nil { + return err + } + newObj, err := meta.Accessor(o) + if err != nil { + return err + } + + // Validate that deletionTimestamp has not been changed + if !deletionTimestampEqual(newObj, oldAccessor) { + return fmt.Errorf("rejected patch, metadata.deletionTimestamp immutable") + } + + reaction := testing.ObjectReaction(c.tracker) + handled, o, err := reaction(action) + if err != nil { + return err + } + if !handled { + panic("tracker could not handle patch method") + } + + if _, isUnstructured := obj.(runtime.Unstructured); isUnstructured { + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + ta.SetKind(gvk.Kind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + return json.Unmarshal(j, obj) +} + +// Applying a patch results in a deletionTimestamp that is truncated to the nearest second. +// Check that the diff between a new and old deletion timestamp is within a reasonable threshold +// to be considered unchanged. +func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool { + newTime := newObj.GetDeletionTimestamp() + oldTime := obj.GetDeletionTimestamp() + + if newTime == nil || oldTime == nil { + return newTime == oldTime + } + return newTime.Time.Sub(oldTime.Time).Abs() < time.Second +} + +// The behavior of applying the patch is pulled out into dryPatch(), +// which applies the patch and returns an object, but does not Update() the object. +// This function returns a patched runtime object that may then be validated before a call to Update() is executed. +// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data +// and easier than refactoring the k8s client-go method upstream. +// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194 +func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) { + ns := action.GetNamespace() + gvr := action.GetResource() + + obj, err := tracker.Get(gvr, ns, action.GetName()) + if err != nil { + return nil, err + } + + old, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + // reset the object in preparation to unmarshal, since unmarshal does not guarantee that fields + // in obj that are removed by patch are cleared + value := reflect.ValueOf(obj) + value.Elem().Set(reflect.New(value.Type().Elem()).Elem()) + + switch action.GetPatchType() { + case types.JSONPatchType: + patch, err := jsonpatch.DecodePatch(action.GetPatch()) + if err != nil { + return nil, err + } + modified, err := patch.Apply(old) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.MergePatchType: + modified, err := jsonpatch.MergePatch(old, action.GetPatch()) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.StrategicMergePatchType: + mergedByte, err := strategicpatch.StrategicMergePatch(old, action.GetPatch(), obj) + if err != nil { + return nil, err + } + if err = json.Unmarshal(mergedByte, obj); err != nil { + return nil, err + } + case types.ApplyPatchType: + return nil, errors.New("apply patches are not supported in the fake client. Follow https://github.com/kubernetes/kubernetes/issues/115598 for the current status") + case types.ApplyCBORPatchType: + return nil, errors.New("apply CBOR patches are not supported in the fake client") + default: + return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType()) + } + return obj, nil +} + +// copyStatusFrom copies the status from old into new +func copyStatusFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + newMapStringAny, err := toMapStringAny(n) + if err != nil { + return fmt.Errorf("failed to convert new to *unststructured.Unstructured: %w", err) + } + + newMapStringAny["status"] = oldMapStringAny["status"] + + if err := fromMapStringAny(newMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +// copyFrom copies from old into new +func copyFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + if err := fromMapStringAny(oldMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +func toMapStringAny(obj runtime.Object) (map[string]any, error) { + if unstructured, isUnstructured := obj.(*unstructured.Unstructured); isUnstructured { + return unstructured.Object, nil + } + + serialized, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + u := map[string]any{} + return u, json.Unmarshal(serialized, &u) +} + +func fromMapStringAny(u map[string]any, target runtime.Object) error { + if targetUnstructured, isUnstructured := target.(*unstructured.Unstructured); isUnstructured { + targetUnstructured.Object = u + return nil + } + + serialized, err := json.Marshal(u) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + + zero(target) + if err := json.Unmarshal(serialized, &target); err != nil { + return fmt.Errorf("failed to deserialize: %w", err) + } + + return nil +} + +func (c *fakeClient) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c *fakeClient) SubResource(subResource string) client.SubResourceClient { + return &fakeSubResourceClient{client: c, subResource: subResource} +} + +func (c *fakeClient) deleteObjectLocked(gvr schema.GroupVersionResource, accessor metav1.Object) error { + old, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err == nil { + oldAccessor, err := meta.Accessor(old) + if err == nil { + if len(oldAccessor.GetFinalizers()) > 0 { + now := metav1.Now() + oldAccessor.SetDeletionTimestamp(&now) + // Call update directly with mutability parameter set to true to allow + // changes to deletionTimestamp + return c.tracker.update(gvr, old, accessor.GetNamespace(), false, true, metav1.UpdateOptions{}) + } + } + } + + // TODO: implement propagation + return c.tracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName()) +} + +func getGVRFromObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionResource, error) { + gvk, err := apiutil.GVKForObject(obj, scheme) + if err != nil { + return schema.GroupVersionResource{}, err + } + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr, nil +} + +type fakeSubResourceClient struct { + client *fakeClient + subResource string +} + +func (sw *fakeSubResourceClient) Get(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceGetOption) error { + switch sw.subResource { + case subResourceScale: + // Actual client looks up resource, then extracts the scale sub-resource: + // https://github.com/kubernetes/kubernetes/blob/fb6bbc9781d11a87688c398778525c4e1dcb0f08/pkg/registry/apps/deployment/storage/storage.go#L307 + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return err + } + scale, isScale := subResource.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", subResource)) + } + scaleOut, err := extractScale(obj) + if err != nil { + return err + } + *scale = *scaleOut + return nil + default: + return fmt.Errorf("fakeSubResourceClient does not support get for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + switch sw.subResource { + case "eviction": + _, isEviction := subResource.(*policyv1beta1.Eviction) + if !isEviction { + _, isEviction = subResource.(*policyv1.Eviction) + } + if !isEviction { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected Eviction", subResource)) + } + if _, isPod := obj.(*corev1.Pod); !isPod { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + return sw.client.Delete(ctx, obj) + case "token": + tokenRequest, isTokenRequest := subResource.(*authenticationv1.TokenRequest) + if !isTokenRequest { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected TokenRequest", subResource)) + } + if _, isServiceAccount := obj.(*corev1.ServiceAccount); !isServiceAccount { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + tokenRequest.Status.Token = "fake-token" + tokenRequest.Status.ExpirationTimestamp = metav1.Date(6041, 1, 1, 0, 0, 0, 0, time.UTC) + + return sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj) + default: + return fmt.Errorf("fakeSubResourceWriter does not support create for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + updateOptions := client.SubResourceUpdateOptions{} + updateOptions.ApplyOptions(opts) + + switch sw.subResource { + case subResourceScale: + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj.DeepCopyObject().(client.Object)); err != nil { + return err + } + if updateOptions.SubResourceBody == nil { + return apierrors.NewBadRequest("missing SubResourceBody") + } + + scale, isScale := updateOptions.SubResourceBody.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", updateOptions.SubResourceBody)) + } + if err := applyScale(obj, scale); err != nil { + return err + } + return sw.client.update(obj, false, &updateOptions.UpdateOptions) + default: + body := obj + if updateOptions.SubResourceBody != nil { + body = updateOptions.SubResourceBody + } + return sw.client.update(body, true, &updateOptions.UpdateOptions) + } +} + +func (sw *fakeSubResourceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + patchOptions := client.SubResourcePatchOptions{} + patchOptions.ApplyOptions(opts) + + body := obj + if patchOptions.SubResourceBody != nil { + body = patchOptions.SubResourceBody + } + + // this is necessary to identify that last call was made for status patch, through stack trace. + if sw.subResource == "status" { + return sw.statusPatch(body, patch, patchOptions) + } + + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func (sw *fakeSubResourceClient) statusPatch(body client.Object, patch client.Patch, patchOptions client.SubResourcePatchOptions) error { + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func allowsUnconditionalUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "apps": + switch gvk.Kind { + case "ControllerRevision", "DaemonSet", "Deployment", "ReplicaSet", "StatefulSet": + return true + } + case "autoscaling": + switch gvk.Kind { + case "HorizontalPodAutoscaler": + return true + } + case "batch": + switch gvk.Kind { + case "CronJob", "Job": + return true + } + case "certificates": + switch gvk.Kind { + case "Certificates": + return true + } + case "flowcontrol": + switch gvk.Kind { + case "FlowSchema", "PriorityLevelConfiguration": + return true + } + case "networking": + switch gvk.Kind { + case "Ingress", "IngressClass", "NetworkPolicy": + return true + } + case "policy": + switch gvk.Kind { + case "PodSecurityPolicy": + return true + } + case "rbac.authorization.k8s.io": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "scheduling": + switch gvk.Kind { + case "PriorityClass": + return true + } + case "settings": + switch gvk.Kind { + case "PodPreset": + return true + } + case "storage": + switch gvk.Kind { + case "StorageClass": + return true + } + case "": + switch gvk.Kind { + case "ConfigMap", "Endpoint", "Event", "LimitRange", "Namespace", "Node", + "PersistentVolume", "PersistentVolumeClaim", "Pod", "PodTemplate", + "ReplicationController", "ResourceQuota", "Secret", "Service", + "ServiceAccount", "EndpointSlice": + return true + } + } + + return false +} + +func allowsCreateOnUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "coordination": + switch gvk.Kind { + case "Lease": + return true + } + case "node": + switch gvk.Kind { + case "RuntimeClass": + return true + } + case "rbac": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "": + switch gvk.Kind { + case "Endpoint", "Event", "LimitRange", "Service": + return true + } + } + + return false +} + +func inTreeResourcesWithStatus() []schema.GroupVersionKind { + return []schema.GroupVersionKind{ + {Version: "v1", Kind: "Namespace"}, + {Version: "v1", Kind: "Node"}, + {Version: "v1", Kind: "PersistentVolumeClaim"}, + {Version: "v1", Kind: "PersistentVolume"}, + {Version: "v1", Kind: "Pod"}, + {Version: "v1", Kind: "ReplicationController"}, + {Version: "v1", Kind: "Service"}, + + {Group: "apps", Version: "v1", Kind: "Deployment"}, + {Group: "apps", Version: "v1", Kind: "DaemonSet"}, + {Group: "apps", Version: "v1", Kind: "ReplicaSet"}, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}, + + {Group: "autoscaling", Version: "v1", Kind: "HorizontalPodAutoscaler"}, + + {Group: "batch", Version: "v1", Kind: "CronJob"}, + {Group: "batch", Version: "v1", Kind: "Job"}, + + {Group: "certificates.k8s.io", Version: "v1", Kind: "CertificateSigningRequest"}, + + {Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"}, + {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"}, + + {Group: "policy", Version: "v1", Kind: "PodDisruptionBudget"}, + + {Group: "storage.k8s.io", Version: "v1", Kind: "VolumeAttachment"}, + + {Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, + + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "PriorityLevelConfiguration"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "PriorityLevelConfiguration"}, + } +} + +// zero zeros the value of a pointer. +func zero(x interface{}) { + if x == nil { + return + } + res := reflect.ValueOf(x).Elem() + res.Set(reflect.Zero(res.Type())) +} + +// getSingleOrZeroOptions returns the single options value in the slice, its +// zero value if the slice is empty, or an error if the slice contains more than +// one option value. +func getSingleOrZeroOptions[T any](opts []T) (opt T, err error) { + switch len(opts) { + case 0: + case 1: + opt = opts[0] + default: + err = fmt.Errorf("expected single or no options value, got %d values", len(opts)) + } + return +} + +func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { + switch obj := obj.(type) { + case *appsv1.Deployment: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *appsv1.ReplicaSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *corev1.ReplicationController: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: labels.Set(obj.Spec.Selector).String(), + }, + }, nil + case *appsv1.StatefulSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return nil, fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } +} + +func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { + switch obj := obj.(type) { + case *appsv1.Deployment: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.ReplicaSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *corev1.ReplicationController: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.StatefulSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } + return nil +} + +// AddIndex adds an index to a fake client. It will panic if used with a client that is not a fake client. +// It will error if there is already an index for given object with the same name as field. +// +// It can be used to test code that adds indexes to the cache at runtime. +func AddIndex(c client.Client, obj runtime.Object, field string, extractValue client.IndexerFunc) error { + fakeClient, isFakeClient := c.(*fakeClient) + if !isFakeClient { + panic("AddIndex can only be used with a fake client") + } + fakeClient.indexesLock.Lock() + defer fakeClient.indexesLock.Unlock() + + if fakeClient.indexes == nil { + fakeClient.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc, 1) + } + + gvk, err := apiutil.GVKForObject(obj, fakeClient.scheme) + if err != nil { + return fmt.Errorf("failed to get gvk for %T: %w", obj, err) + } + + if fakeClient.indexes[gvk] == nil { + fakeClient.indexes[gvk] = make(map[string]client.IndexerFunc, 1) + } + + if fakeClient.indexes[gvk][field] != nil { + return fmt.Errorf("index %s already exists", field) + } + + fakeClient.indexes[gvk][field] = extractValue + + return nil +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go new file mode 100644 index 00000000..47cad398 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go @@ -0,0 +1,38 @@ +/* +Copyright 2018 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 fake provides a fake client for testing. + +A fake client is backed by its simple object store indexed by GroupVersionResource. +You can create a fake client with optional objects. + + client := NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + +You can invoke the methods defined in the Client interface. + +When in doubt, it's almost always better not to use this package and instead use +envtest.Environment with a real client and API server. + +WARNING: ⚠️ Current Limitations / Known Issues with the fake Client ⚠️ + - This client does not have a way to inject specific errors to test handled vs. unhandled errors. + - There is some support for sub resources which can cause issues with tests if you're trying to update + e.g. metadata and status in the same reconcile. + - No OpenAPI validation is performed when creating or updating objects. + - ObjectMeta's `Generation` and `ResourceVersion` don't behave properly, Patch or Update + operations that rely on these fields will fail, or give false positives. +*/ +package fake diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go new file mode 100644 index 00000000..3d3f3cb0 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go @@ -0,0 +1,166 @@ +package interceptor + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Funcs contains functions that are called instead of the underlying client's methods. +type Funcs struct { + Get func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error + List func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error + Create func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error + Delete func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error + DeleteAllOf func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteAllOfOption) error + Update func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error + Patch func(ctx context.Context, client client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error + Watch func(ctx context.Context, client client.WithWatch, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) + SubResource func(client client.WithWatch, subResource string) client.SubResourceClient + SubResourceGet func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error + SubResourceCreate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error + SubResourceUpdate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error + SubResourcePatch func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error +} + +// NewClient returns a new interceptor client that calls the functions in funcs instead of the underlying client's methods, if they are not nil. +func NewClient(interceptedClient client.WithWatch, funcs Funcs) client.WithWatch { + return interceptor{ + client: interceptedClient, + funcs: funcs, + } +} + +type interceptor struct { + client client.WithWatch + funcs Funcs +} + +var _ client.WithWatch = &interceptor{} + +func (c interceptor) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return c.client.GroupVersionKindFor(obj) +} + +func (c interceptor) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return c.client.IsObjectNamespaced(obj) +} + +func (c interceptor) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.funcs.Get != nil { + return c.funcs.Get(ctx, c.client, key, obj, opts...) + } + return c.client.Get(ctx, key, obj, opts...) +} + +func (c interceptor) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if c.funcs.List != nil { + return c.funcs.List(ctx, c.client, list, opts...) + } + return c.client.List(ctx, list, opts...) +} + +func (c interceptor) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if c.funcs.Create != nil { + return c.funcs.Create(ctx, c.client, obj, opts...) + } + return c.client.Create(ctx, obj, opts...) +} + +func (c interceptor) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if c.funcs.Delete != nil { + return c.funcs.Delete(ctx, c.client, obj, opts...) + } + return c.client.Delete(ctx, obj, opts...) +} + +func (c interceptor) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if c.funcs.Update != nil { + return c.funcs.Update(ctx, c.client, obj, opts...) + } + return c.client.Update(ctx, obj, opts...) +} + +func (c interceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if c.funcs.Patch != nil { + return c.funcs.Patch(ctx, c.client, obj, patch, opts...) + } + return c.client.Patch(ctx, obj, patch, opts...) +} + +func (c interceptor) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + if c.funcs.DeleteAllOf != nil { + return c.funcs.DeleteAllOf(ctx, c.client, obj, opts...) + } + return c.client.DeleteAllOf(ctx, obj, opts...) +} + +func (c interceptor) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c interceptor) SubResource(subResource string) client.SubResourceClient { + if c.funcs.SubResource != nil { + return c.funcs.SubResource(c.client, subResource) + } + return subResourceInterceptor{ + subResourceName: subResource, + client: c.client, + funcs: c.funcs, + } +} + +func (c interceptor) Scheme() *runtime.Scheme { + return c.client.Scheme() +} + +func (c interceptor) RESTMapper() meta.RESTMapper { + return c.client.RESTMapper() +} + +func (c interceptor) Watch(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + if c.funcs.Watch != nil { + return c.funcs.Watch(ctx, c.client, obj, opts...) + } + return c.client.Watch(ctx, obj, opts...) +} + +type subResourceInterceptor struct { + subResourceName string + client client.Client + funcs Funcs +} + +var _ client.SubResourceClient = &subResourceInterceptor{} + +func (s subResourceInterceptor) Get(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error { + if s.funcs.SubResourceGet != nil { + return s.funcs.SubResourceGet(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Get(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + if s.funcs.SubResourceCreate != nil { + return s.funcs.SubResourceCreate(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Create(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if s.funcs.SubResourceUpdate != nil { + return s.funcs.SubResourceUpdate(ctx, s.client, s.subResourceName, obj, opts...) + } + return s.client.SubResource(s.subResourceName).Update(ctx, obj, opts...) +} + +func (s subResourceInterceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if s.funcs.SubResourcePatch != nil { + return s.funcs.SubResourcePatch(ctx, s.client, s.subResourceName, obj, patch, opts...) + } + return s.client.SubResource(s.subResourceName).Patch(ctx, obj, patch, opts...) +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go new file mode 100644 index 00000000..0189c043 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go @@ -0,0 +1,42 @@ +/* +Copyright 2018 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 objectutil + +import ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +// FilterWithLabels returns a copy of the items in objs matching labelSel. +func FilterWithLabels(objs []runtime.Object, labelSel labels.Selector) ([]runtime.Object, error) { + outItems := make([]runtime.Object, 0, len(objs)) + for _, obj := range objs { + meta, err := apimeta.Accessor(obj) + if err != nil { + return nil, err + } + if labelSel != nil { + lbls := labels.Set(meta.GetLabels()) + if !labelSel.Matches(lbls) { + continue + } + } + outItems = append(outItems, obj.DeepCopyObject()) + } + return outItems, nil +} From 0b61234a2be7e9cd493ceace26504367e439c311 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 16:26:37 +0530 Subject: [PATCH 10/15] feat(controller): add CheckpointSchedule reconciler Signed-off-by: Rupam-It --- .../checkpointschedule_controller.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/controller/checkpointschedule_controller.go diff --git a/internal/controller/checkpointschedule_controller.go b/internal/controller/checkpointschedule_controller.go new file mode 100644 index 00000000..be81feed --- /dev/null +++ b/internal/controller/checkpointschedule_controller.go @@ -0,0 +1,62 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type CheckpointScheduleReconciler struct { + client.Client + Scheme *runtime.Scheme + RestConfig *rest.Config +} + +func (r *CheckpointScheduleReconciler) Reconcile( + ctx context.Context, + req ctrl.Request, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + // fetch the CR + schedule := &v1.CheckpointSchedule{} + if err := r.Get(ctx, req.NamespacedName, schedule); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + creator := NewCheckpointCreator(r.Client, r.RestConfig) + + trigger := NewScheduleTrigger(r.Client, creator, schedule) + if err := trigger.Start(ctx); err != nil { + logger.Error(err, "failed to start schedule trigger") + return ctrl.Result{}, err + } + + logger.Info("schedule trigger started", "name", schedule.Name) + return ctrl.Result{}, nil + +} +func (r *CheckpointScheduleReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1.CheckpointSchedule{}). + Complete(r) +} From 2d5c104cb57d034355bd399adb7eec2116b33be3 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 16:34:31 +0530 Subject: [PATCH 11/15] feat(controller): register CheckpointSchedule controller and update RBAC Signed-off-by: Rupam-It --- cmd/main.go | 8 ++++++++ config/crd/kustomization.yaml | 1 + config/rbac/role.yaml | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index ad73ae63..ab0fca99 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -96,6 +96,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "CheckpointRestoreOperator") os.Exit(1) } + if err = (&controller.CheckpointScheduleReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + RestConfig: mgr.GetConfig(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "CheckpointSchedule") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 6a59dfe0..b65d0a41 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/criu.org_checkpointrestoreoperators.yaml +- bases/criu.org_checkpointschedules.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 833ece98..70369f24 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -46,3 +46,36 @@ rules: - get - patch - update +- apiGroups: + - criu.org + resources: + - checkpointschedules + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - criu.org + resources: + - checkpointschedules/status + verbs: + - get + - patch + - update +- apiGroups: + - criu.org + resources: + - checkpointschedules/finalizers + verbs: + - update +- apiGroups: + - "" + resources: + - nodes/proxy + verbs: + - get + - create \ No newline at end of file From a04f76f6f7f7d680c36254ec09f16864a5693235 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 20 May 2026 17:01:15 +0530 Subject: [PATCH 12/15] fix: add RBAC markers for CheckpointSchedule and handle incomplete checkpoint archives Signed-off-by: Rupam-It --- config/rbac/role.yaml | 19 ++++++------------- .../checkpointrestoreoperator_controller.go | 11 ++++++++--- .../checkpointschedule_controller.go | 4 ++++ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 70369f24..3e103792 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -51,21 +51,13 @@ rules: resources: - checkpointschedules verbs: - - get - - list - - watch - create - - update - - patch - delete -- apiGroups: - - criu.org - resources: - - checkpointschedules/status - verbs: - get + - list - patch - update + - watch - apiGroups: - criu.org resources: @@ -73,9 +65,10 @@ rules: verbs: - update - apiGroups: - - "" + - criu.org resources: - - nodes/proxy + - checkpointschedules/status verbs: - get - - create \ No newline at end of file + - patch + - update diff --git a/internal/controller/checkpointrestoreoperator_controller.go b/internal/controller/checkpointrestoreoperator_controller.go index 594ad02a..afe46633 100644 --- a/internal/controller/checkpointrestoreoperator_controller.go +++ b/internal/controller/checkpointrestoreoperator_controller.go @@ -333,7 +333,11 @@ func getCheckpointArchiveInformation(log logr.Logger, checkpointPath string) (*c } labels := make(map[string]string) - if err := json.Unmarshal([]byte(dumpSpec.Annotations["io.kubernetes.cri-o.Labels"]), &labels); err != nil { + rawLabels := dumpSpec.Annotations["io.kubernetes.cri-o.Labels"] + if rawLabels == "" { + return nil, fmt.Errorf("failed to read %q: annotation is empty, archive may still be written", "io.kubernetes.cri-o.Labels") + } + if err := json.Unmarshal([]byte(rawLabels), &labels); err != nil { return nil, fmt.Errorf("failed to read %q: %w", "io.kubernetes.cri-o.Labels", err) } @@ -863,8 +867,9 @@ func (gc *garbageCollector) runGarbageCollector() { // This is based on that example code. var ( - // Wait 100ms for new events; each new event resets the timer. - waitFor = 100 * time.Millisecond + // Wait 1s for new events; each new event resets the timer. + // 100ms was too short for large checkpoint archives still being written. + waitFor = 1 * time.Second // Keep track of the timers, as path → timer. mu sync.Mutex diff --git a/internal/controller/checkpointschedule_controller.go b/internal/controller/checkpointschedule_controller.go index be81feed..10aa83ec 100644 --- a/internal/controller/checkpointschedule_controller.go +++ b/internal/controller/checkpointschedule_controller.go @@ -27,6 +27,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) +//+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/finalizers,verbs=update + type CheckpointScheduleReconciler struct { client.Client Scheme *runtime.Scheme From de6b470e861bcf191d374e197021de65cac94bfd Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Fri, 29 May 2026 10:11:11 +0530 Subject: [PATCH 13/15] feat(controller): add annotation-based checkpoint trigger Signed-off-by: Rupam-It --- config/rbac/role.yaml | 1 + internal/controller/annotation_trigger.go | 83 ++++++++++++++++++ .../controller/annotation_trigger_test.go | 74 ++++++++++++++++ .../checkpointschedule_controller.go | 86 +++++++++++++++++-- 4 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 internal/controller/annotation_trigger.go create mode 100644 internal/controller/annotation_trigger_test.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3e103792..78d26fb9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -19,6 +19,7 @@ rules: verbs: - get - list + - patch - watch - apiGroups: - criu.org diff --git a/internal/controller/annotation_trigger.go b/internal/controller/annotation_trigger.go new file mode 100644 index 00000000..cbf5be36 --- /dev/null +++ b/internal/controller/annotation_trigger.go @@ -0,0 +1,83 @@ +package controller + +import ( + "context" + "time" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const CheckpointTriggerAnnotation = "checkpoint.criu.org/trigger" + +type AnnotationTrigger struct { + client client.Client + creator Checkpointer + schedule *v1.CheckpointSchedule + stopCh chan struct{} + interval time.Duration +} + +func NewAnnotationTrigger(c client.Client, creator Checkpointer, schedule *v1.CheckpointSchedule) *AnnotationTrigger { + return &AnnotationTrigger{ + client: c, + creator: creator, + schedule: schedule, + stopCh: make(chan struct{}), + interval: 30 * time.Second, + } +} + +func (at *AnnotationTrigger) Start(ctx context.Context) { + logger := log.FromContext(ctx) + logger.Info("annotation trigger started", "interval", at.interval) + + go func() { + ticker := time.NewTicker(at.interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + at.run(ctx) + case <-at.stopCh: + return + case <-ctx.Done(): + return + } + } + }() +} + +func (at *AnnotationTrigger) run(ctx context.Context) { + logger := log.FromContext(ctx) + + pods, err := getMatchingPods(ctx, at.client, at.schedule.Spec.Namespace, &at.schedule.Spec.Selector) + if err != nil { + logger.Error(err, "annotation trigger: failed to list pods") + return + } + + for _, pod := range pods { + if pod.Annotations[CheckpointTriggerAnnotation] != "true" { + continue + } + + for _, c := range pod.Spec.Containers { + if err := at.creator.createCheckpoint(ctx, pod.Namespace, pod.Name, c.Name, pod.Spec.NodeName); err != nil { + logger.Error(err, "annotation trigger: checkpoint failed", "pod", pod.Name, "container", c.Name) + } + } + + // clear the annotation so we don't checkpoint again next poll + patch := client.MergeFrom(pod.DeepCopy()) + delete(pod.Annotations, CheckpointTriggerAnnotation) + if err := at.client.Patch(ctx, &pod, patch); err != nil { + logger.Error(err, "annotation trigger: failed to clear annotation", "pod", pod.Name) + } + } +} + +func (at *AnnotationTrigger) Stop() { + close(at.stopCh) +} diff --git a/internal/controller/annotation_trigger_test.go b/internal/controller/annotation_trigger_test.go new file mode 100644 index 00000000..a1dc2ab5 --- /dev/null +++ b/internal/controller/annotation_trigger_test.go @@ -0,0 +1,74 @@ +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" +) + +func makeAnnotationTrigger(mock *mockCheckpointer, pods ...*corev1.Pod) *AnnotationTrigger { + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + for _, p := range pods { + builder = builder.WithObjects(p).WithStatusSubresource(p) + } + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Namespace: "default", + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}}, + }, + } + return NewAnnotationTrigger(builder.Build(), mock, sched) +} + +var _ = Describe("AnnotationTrigger.run", func() { + var mock *mockCheckpointer + + BeforeEach(func() { + mock = &mockCheckpointer{} + }) + + It("checkpoints a pod that has the trigger annotation and clears it", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", Namespace: "default", + Labels: map[string]string{"app": "test"}, + Annotations: map[string]string{CheckpointTriggerAnnotation: "true"}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + trigger := makeAnnotationTrigger(mock, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].container).To(Equal("app")) + + // annotation must be cleared + updated := &corev1.Pod{} + Expect(trigger.client.Get(context.Background(), client.ObjectKeyFromObject(pod), updated)).To(Succeed()) + Expect(updated.Annotations).NotTo(HaveKey(CheckpointTriggerAnnotation)) + }) + + It("skips pods that do not have the trigger annotation", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app"}}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + trigger := makeAnnotationTrigger(mock, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) +}) diff --git a/internal/controller/checkpointschedule_controller.go b/internal/controller/checkpointschedule_controller.go index 10aa83ec..8d9e4027 100644 --- a/internal/controller/checkpointschedule_controller.go +++ b/internal/controller/checkpointschedule_controller.go @@ -18,23 +18,34 @@ package controller import ( "context" + "sync" v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" ) //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/status,verbs=get;update;patch //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch + +const checkpointScheduleFinalizer = "criu.org/checkpoint-schedule-finalizer" + +type Stoppable interface { + Stop() +} type CheckpointScheduleReconciler struct { client.Client - Scheme *runtime.Scheme - RestConfig *rest.Config + Scheme *runtime.Scheme + RestConfig *rest.Config + activeTriggers map[string][]Stoppable + mu sync.Mutex } func (r *CheckpointScheduleReconciler) Reconcile( @@ -42,23 +53,82 @@ func (r *CheckpointScheduleReconciler) Reconcile( req ctrl.Request, ) (ctrl.Result, error) { logger := log.FromContext(ctx) - // fetch the CR + schedule := &v1.CheckpointSchedule{} if err := r.Get(ctx, req.NamespacedName, schedule); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } + + key := req.NamespacedName.String() + + // CR is being deleted — stop triggers and remove finalizer + if !schedule.DeletionTimestamp.IsZero() { + r.stopTriggers(key) + if controllerutil.ContainsFinalizer(schedule, checkpointScheduleFinalizer) { + controllerutil.RemoveFinalizer(schedule, checkpointScheduleFinalizer) + if err := r.Update(ctx, schedule); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + // add finalizer if not present + if !controllerutil.ContainsFinalizer(schedule, checkpointScheduleFinalizer) { + controllerutil.AddFinalizer(schedule, checkpointScheduleFinalizer) + if err := r.Update(ctx, schedule); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + logger.Info("reconciling", "name", schedule.Name) + + // already running — don't start duplicates + r.mu.Lock() + _, running := r.activeTriggers[key] + r.mu.Unlock() + if running { + return ctrl.Result{}, nil + } + creator := NewCheckpointCreator(r.Client, r.RestConfig) + var triggers []Stoppable + + if schedule.Spec.Triggers.Schedule != "" { + t := NewScheduleTrigger(r.Client, creator, schedule) + if err := t.Start(ctx); err != nil { + logger.Error(err, "failed to start schedule trigger") + return ctrl.Result{}, err + } + triggers = append(triggers, t) + } - trigger := NewScheduleTrigger(r.Client, creator, schedule) - if err := trigger.Start(ctx); err != nil { - logger.Error(err, "failed to start schedule trigger") - return ctrl.Result{}, err + if schedule.Spec.Triggers.OnAnnotation { + t := NewAnnotationTrigger(r.Client, creator, schedule) + t.Start(ctx) + triggers = append(triggers, t) } - logger.Info("schedule trigger started", "name", schedule.Name) + r.mu.Lock() + if r.activeTriggers == nil { + r.activeTriggers = make(map[string][]Stoppable) + } + r.activeTriggers[key] = triggers + r.mu.Unlock() + return ctrl.Result{}, nil +} +func (r *CheckpointScheduleReconciler) stopTriggers(key string) { + r.mu.Lock() + defer r.mu.Unlock() + for _, t := range r.activeTriggers[key] { + t.Stop() + } + delete(r.activeTriggers, key) } + func (r *CheckpointScheduleReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1.CheckpointSchedule{}). From 9757d2ee614ae1a5ac02cb37a82596f0961d44db Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Mon, 1 Jun 2026 09:09:05 +0530 Subject: [PATCH 14/15] feat(controller): add resource-based checkpoint trigger with cpu and memory upper/lower thresholds Signed-off-by: Rupam-It --- api/v1/checkpointschedule_types.go | 12 +- api/v1/zz_generated.deepcopy.go | 33 ++- .../bases/criu.org_checkpointschedules.yaml | 18 +- config/rbac/role.yaml | 14 + .../samples/criu_v1_checkpointschedule.yaml | 21 ++ .../checkpointschedule_controller.go | 8 + internal/controller/resource_trigger.go | 236 +++++++++++++++++ internal/controller/resource_trigger_test.go | 248 ++++++++++++++++++ 8 files changed, 581 insertions(+), 9 deletions(-) create mode 100644 config/samples/criu_v1_checkpointschedule.yaml create mode 100644 internal/controller/resource_trigger.go create mode 100644 internal/controller/resource_trigger_test.go diff --git a/api/v1/checkpointschedule_types.go b/api/v1/checkpointschedule_types.go index f922997f..74c19aa2 100644 --- a/api/v1/checkpointschedule_types.go +++ b/api/v1/checkpointschedule_types.go @@ -44,10 +44,16 @@ type TriggersSpec struct { OnAnnotation bool `json:"onAnnotation,omitempty"` } +// ResourcePercentThreshold defines upper and lower percentage bounds for a resource. +type ResourcePercentThreshold struct { + Upper *int `json:"upper,omitempty"` + Lower *int `json:"lower,omitempty"` +} + type ResourceThresholdSpec struct { - CPUPercent *int `json:"cpuPercent,omitempty"` - MemoryPercent *int `json:"memoryPercent,omitempty"` - PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` + CPUPercent *ResourcePercentThreshold `json:"cpuPercent,omitempty"` + MemoryPercent *ResourcePercentThreshold `json:"memoryPercent,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` } // CheckpointScheduleStatus defines the observed state of CheckpointSchedule diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 43051180..18a73f68 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -403,18 +403,43 @@ func (in *PodPolicySpec) DeepCopy() *PodPolicySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourcePercentThreshold) DeepCopyInto(out *ResourcePercentThreshold) { + *out = *in + if in.Upper != nil { + in, out := &in.Upper, &out.Upper + *out = new(int) + **out = **in + } + if in.Lower != nil { + in, out := &in.Lower, &out.Lower + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourcePercentThreshold. +func (in *ResourcePercentThreshold) DeepCopy() *ResourcePercentThreshold { + if in == nil { + return nil + } + out := new(ResourcePercentThreshold) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceThresholdSpec) DeepCopyInto(out *ResourceThresholdSpec) { *out = *in if in.CPUPercent != nil { in, out := &in.CPUPercent, &out.CPUPercent - *out = new(int) - **out = **in + *out = new(ResourcePercentThreshold) + (*in).DeepCopyInto(*out) } if in.MemoryPercent != nil { in, out := &in.MemoryPercent, &out.MemoryPercent - *out = new(int) - **out = **in + *out = new(ResourcePercentThreshold) + (*in).DeepCopyInto(*out) } if in.PollIntervalSeconds != nil { in, out := &in.PollIntervalSeconds, &out.PollIntervalSeconds diff --git a/config/crd/bases/criu.org_checkpointschedules.yaml b/config/crd/bases/criu.org_checkpointschedules.yaml index 48d7a7d7..570811b5 100644 --- a/config/crd/bases/criu.org_checkpointschedules.yaml +++ b/config/crd/bases/criu.org_checkpointschedules.yaml @@ -107,9 +107,23 @@ spec: resourceThreshold: properties: cpuPercent: - type: integer + description: ResourcePercentThreshold defines upper and lower + percentage bounds for a resource. + properties: + lower: + type: integer + upper: + type: integer + type: object memoryPercent: - type: integer + description: ResourcePercentThreshold defines upper and lower + percentage bounds for a resource. + properties: + lower: + type: integer + upper: + type: integer + type: object pollIntervalSeconds: type: integer type: object diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 78d26fb9..9a548088 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -12,6 +12,13 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - nodes/proxy + verbs: + - create + - get - apiGroups: - "" resources: @@ -73,3 +80,10 @@ rules: - get - patch - update +- apiGroups: + - metrics.k8s.io + resources: + - pods + verbs: + - get + - list diff --git a/config/samples/criu_v1_checkpointschedule.yaml b/config/samples/criu_v1_checkpointschedule.yaml new file mode 100644 index 00000000..194b084d --- /dev/null +++ b/config/samples/criu_v1_checkpointschedule.yaml @@ -0,0 +1,21 @@ +apiVersion: criu.org/v1 +kind: CheckpointSchedule +metadata: + name: myapp-protection + namespace: default +spec: + namespace: default + selector: + matchLabels: + app: myapp + containerNames: [] # empty = all containers + intent: ResourcePressure + triggers: + resourceThreshold: + memoryPercent: + upper: 80 # checkpoint when memory > 80% of limit + lower: 25 # checkpoint when memory < 25% of limit + cpuPercent: + upper: 90 # checkpoint when CPU > 90% of limit + lower: 5 # checkpoint when CPU < 5% of limit + pollIntervalSeconds: 30 \ No newline at end of file diff --git a/internal/controller/checkpointschedule_controller.go b/internal/controller/checkpointschedule_controller.go index 8d9e4027..ac9132bd 100644 --- a/internal/controller/checkpointschedule_controller.go +++ b/internal/controller/checkpointschedule_controller.go @@ -33,6 +33,8 @@ import ( //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/status,verbs=get;update;patch //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch +//+kubebuilder:rbac:groups="",resources=nodes/proxy,verbs=get;create +//+kubebuilder:rbac:groups=metrics.k8s.io,resources=pods,verbs=get;list const checkpointScheduleFinalizer = "criu.org/checkpoint-schedule-finalizer" @@ -110,6 +112,12 @@ func (r *CheckpointScheduleReconciler) Reconcile( triggers = append(triggers, t) } + if schedule.Spec.Triggers.ResourceThreshold != nil { + t := NewResourceTrigger(r.Client, r.RestConfig, creator, schedule) + t.Start(ctx) + triggers = append(triggers, t) + } + r.mu.Lock() if r.activeTriggers == nil { r.activeTriggers = make(map[string][]Stoppable) diff --git a/internal/controller/resource_trigger.go b/internal/controller/resource_trigger.go new file mode 100644 index 00000000..61304df9 --- /dev/null +++ b/internal/controller/resource_trigger.go @@ -0,0 +1,236 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + defaultResourcePollInterval = 30 * time.Second + checkpointCooldown = 5 * time.Minute +) + +// podMetricsResponse is a minimal representation of the metrics.k8s.io/v1beta1 PodMetrics object. +type podMetricsResponse struct { + Containers []containerMetricsItem `json:"containers"` +} + +type containerMetricsItem struct { + Name string `json:"name"` + Usage map[string]string `json:"usage"` +} + +type ResourceTrigger struct { + client client.Client + restConfig *rest.Config + creator Checkpointer + schedule *v1.CheckpointSchedule + stopCh chan struct{} + interval time.Duration + + mu sync.Mutex + lastCheckpoint map[string]time.Time // "pod/container" → last checkpoint time +} + +func NewResourceTrigger(c client.Client, restConfig *rest.Config, creator Checkpointer, schedule *v1.CheckpointSchedule) *ResourceTrigger { + interval := defaultResourcePollInterval + if rt := schedule.Spec.Triggers.ResourceThreshold; rt != nil && rt.PollIntervalSeconds != nil { + interval = time.Duration(*rt.PollIntervalSeconds) * time.Second + } + return &ResourceTrigger{ + client: c, + restConfig: restConfig, + creator: creator, + schedule: schedule, + stopCh: make(chan struct{}), + interval: interval, + lastCheckpoint: make(map[string]time.Time), + } +} + +func (rt *ResourceTrigger) Start(ctx context.Context) { + logger := log.FromContext(ctx) + logger.Info("resource trigger started", "interval", rt.interval) + + go func() { + ticker := time.NewTicker(rt.interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + rt.run(ctx) + case <-rt.stopCh: + return + case <-ctx.Done(): + return + } + } + }() +} + +func (rt *ResourceTrigger) Stop() { + close(rt.stopCh) +} + +func (rt *ResourceTrigger) run(ctx context.Context) { + logger := log.FromContext(ctx) + spec := rt.schedule.Spec.Triggers.ResourceThreshold + if spec == nil { + return + } + + pods, err := getMatchingPods(ctx, rt.client, rt.schedule.Spec.Namespace, &rt.schedule.Spec.Selector) + if err != nil { + logger.Error(err, "resource trigger: failed to list pods") + return + } + + httpClient, err := rest.HTTPClientFor(rt.restConfig) + if err != nil { + logger.Error(err, "resource trigger: failed to build HTTP client") + return + } + + for i := range pods { + pod := &pods[i] + metrics, err := rt.fetchPodMetrics(ctx, httpClient, pod.Namespace, pod.Name) + if err != nil { + logger.Error(err, "resource trigger: failed to fetch metrics", "pod", pod.Name) + continue + } + + // index container limits by name for O(1) lookup + limits := make(map[string]corev1.ResourceList, len(pod.Spec.Containers)) + for _, c := range pod.Spec.Containers { + limits[c.Name] = c.Resources.Limits + } + + for _, cm := range metrics.Containers { + containerLimits := limits[cm.Name] + triggered, reason := rt.thresholdExceeded(cm, containerLimits, spec) + if !triggered { + continue + } + + key := pod.Name + "/" + cm.Name + rt.mu.Lock() + last := rt.lastCheckpoint[key] + rt.mu.Unlock() + if time.Since(last) < checkpointCooldown { + continue + } + + logger.Info("resource trigger: threshold exceeded, checkpointing", + "pod", pod.Name, "container", cm.Name, "reason", reason) + + if err := rt.creator.createCheckpoint(ctx, pod.Namespace, pod.Name, cm.Name, pod.Spec.NodeName); err != nil { + logger.Error(err, "resource trigger: checkpoint failed", "pod", pod.Name, "container", cm.Name) + continue + } + + rt.mu.Lock() + rt.lastCheckpoint[key] = time.Now() + rt.mu.Unlock() + } + } +} + +// thresholdExceeded returns true when any configured upper or lower threshold is breached. +func (rt *ResourceTrigger) thresholdExceeded(cm containerMetricsItem, limits corev1.ResourceList, spec *v1.ResourceThresholdSpec) (bool, string) { + if spec.MemoryPercent != nil { + if pct, ok := memoryUsagePercent(cm, limits); ok { + if spec.MemoryPercent.Upper != nil && pct >= int64(*spec.MemoryPercent.Upper) { + return true, fmt.Sprintf("memory %d%% >= upper threshold %d%%", pct, *spec.MemoryPercent.Upper) + } + if spec.MemoryPercent.Lower != nil && pct <= int64(*spec.MemoryPercent.Lower) { + return true, fmt.Sprintf("memory %d%% <= lower threshold %d%%", pct, *spec.MemoryPercent.Lower) + } + } + } + if spec.CPUPercent != nil { + if pct, ok := cpuUsagePercent(cm, limits); ok { + if spec.CPUPercent.Upper != nil && pct >= int64(*spec.CPUPercent.Upper) { + return true, fmt.Sprintf("CPU %d%% >= upper threshold %d%%", pct, *spec.CPUPercent.Upper) + } + if spec.CPUPercent.Lower != nil && pct <= int64(*spec.CPUPercent.Lower) { + return true, fmt.Sprintf("CPU %d%% <= lower threshold %d%%", pct, *spec.CPUPercent.Lower) + } + } + } + return false, "" +} + +func (rt *ResourceTrigger) fetchPodMetrics(ctx context.Context, httpClient *http.Client, ns, podName string) (*podMetricsResponse, error) { + url := rt.restConfig.Host + "/apis/metrics.k8s.io/v1beta1/namespaces/" + ns + "/pods/" + podName + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metrics API returned %d for %s/%s", resp.StatusCode, ns, podName) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var m podMetricsResponse + if err := json.Unmarshal(body, &m); err != nil { + return nil, err + } + return &m, nil +} + +// memoryUsagePercent returns (usagePercent, true) when both usage and a non-zero limit are available. +func memoryUsagePercent(cm containerMetricsItem, limits corev1.ResourceList) (int64, bool) { + usageStr, ok := cm.Usage["memory"] + if !ok { + return 0, false + } + usage, err := resource.ParseQuantity(usageStr) + if err != nil { + return 0, false + } + limit, ok := limits[corev1.ResourceMemory] + if !ok || limit.IsZero() { + return 0, false + } + return (usage.Value() * 100) / limit.Value(), true +} + +// cpuUsagePercent returns (usagePercent, true) when both usage and a non-zero limit are available. +func cpuUsagePercent(cm containerMetricsItem, limits corev1.ResourceList) (int64, bool) { + usageStr, ok := cm.Usage["cpu"] + if !ok { + return 0, false + } + usage, err := resource.ParseQuantity(usageStr) + if err != nil { + return 0, false + } + limit, ok := limits[corev1.ResourceCPU] + if !ok || limit.IsZero() { + return 0, false + } + return (usage.MilliValue() * 100) / limit.MilliValue(), true +} diff --git a/internal/controller/resource_trigger_test.go b/internal/controller/resource_trigger_test.go new file mode 100644 index 00000000..2bb74dad --- /dev/null +++ b/internal/controller/resource_trigger_test.go @@ -0,0 +1,248 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" +) + +func intPtr(i int) *int { return &i } + +func buildResourceTrigger(mock *mockCheckpointer, serverURL string, threshold *v1.ResourceThresholdSpec, pods ...*corev1.Pod) *ResourceTrigger { + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + for _, p := range pods { + builder = builder.WithObjects(p).WithStatusSubresource(p) + } + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Namespace: "default", + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}}, + Triggers: v1.TriggersSpec{ResourceThreshold: threshold}, + }, + } + return NewResourceTrigger(builder.Build(), &rest.Config{Host: serverURL}, mock, sched) +} + +func runningPodWithLimits(name string, memLimit, cpuLimit string) *corev1.Pod { + limits := corev1.ResourceList{} + if memLimit != "" { + limits[corev1.ResourceMemory] = resource.MustParse(memLimit) + } + if cpuLimit != "" { + limits[corev1.ResourceCPU] = resource.MustParse(cpuLimit) + } + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{ + NodeName: "node-1", + Containers: []corev1.Container{{ + Name: "app", + Resources: corev1.ResourceRequirements{Limits: limits}, + }}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } +} + +var _ = Describe("memoryUsagePercent", func() { + It("returns correct percent when limit is set", func() { + cm := containerMetricsItem{Name: "app", Usage: map[string]string{"memory": "400Mi"}} + limits := corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("500Mi")} + pct, ok := memoryUsagePercent(cm, limits) + Expect(ok).To(BeTrue()) + Expect(pct).To(Equal(int64(80))) + }) + + It("returns false when no memory limit is set", func() { + cm := containerMetricsItem{Name: "app", Usage: map[string]string{"memory": "100Mi"}} + _, ok := memoryUsagePercent(cm, corev1.ResourceList{}) + Expect(ok).To(BeFalse()) + }) + + It("returns false when usage key is missing", func() { + cm := containerMetricsItem{Name: "app", Usage: map[string]string{}} + limits := corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("500Mi")} + _, ok := memoryUsagePercent(cm, limits) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("cpuUsagePercent", func() { + It("returns correct percent when limit is set", func() { + cm := containerMetricsItem{Name: "app", Usage: map[string]string{"cpu": "500m"}} + limits := corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m")} + pct, ok := cpuUsagePercent(cm, limits) + Expect(ok).To(BeTrue()) + Expect(pct).To(Equal(int64(50))) + }) + + It("returns false when no CPU limit is set", func() { + cm := containerMetricsItem{Name: "app", Usage: map[string]string{"cpu": "200m"}} + _, ok := cpuUsagePercent(cm, corev1.ResourceList{}) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("ResourceTrigger.run", func() { + var ( + mock *mockCheckpointer + server *httptest.Server + serverResp podMetricsResponse + ) + + BeforeEach(func() { + mock = &mockCheckpointer{} + serverResp = podMetricsResponse{} + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(serverResp) + })) + }) + + AfterEach(func() { server.Close() }) + + // --- memory upper threshold --- + + It("checkpoints when memory exceeds the upper threshold", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"memory": "420Mi"}}, + }, + } + pod := runningPodWithLimits("pod-1", "500Mi", "") + threshold := &v1.ResourceThresholdSpec{ + MemoryPercent: &v1.ResourcePercentThreshold{Upper: intPtr(80)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].pod).To(Equal("pod-1")) + Expect(mock.calls[0].container).To(Equal("app")) + }) + + It("does not checkpoint when memory is between lower and upper", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"memory": "250Mi"}}, + }, + } + pod := runningPodWithLimits("pod-1", "500Mi", "") + threshold := &v1.ResourceThresholdSpec{ + MemoryPercent: &v1.ResourcePercentThreshold{Upper: intPtr(80), Lower: intPtr(25)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) + + // --- memory lower threshold --- + + It("checkpoints when memory drops below the lower threshold", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"memory": "100Mi"}}, + }, + } + pod := runningPodWithLimits("pod-1", "500Mi", "") + threshold := &v1.ResourceThresholdSpec{ + MemoryPercent: &v1.ResourcePercentThreshold{Lower: intPtr(25)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].pod).To(Equal("pod-1")) + }) + + // --- CPU upper threshold --- + + It("checkpoints when CPU exceeds the upper threshold", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"cpu": "950m"}}, + }, + } + pod := runningPodWithLimits("pod-1", "", "1000m") + threshold := &v1.ResourceThresholdSpec{ + CPUPercent: &v1.ResourcePercentThreshold{Upper: intPtr(90)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].container).To(Equal("app")) + }) + + // --- CPU lower threshold --- + + It("checkpoints when CPU drops below the lower threshold", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"cpu": "30m"}}, + }, + } + pod := runningPodWithLimits("pod-1", "", "1000m") + threshold := &v1.ResourceThresholdSpec{ + CPUPercent: &v1.ResourcePercentThreshold{Lower: intPtr(5)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].container).To(Equal("app")) + }) + + // --- cooldown --- + + It("respects the cooldown and does not checkpoint twice in quick succession", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"memory": "420Mi"}}, + }, + } + pod := runningPodWithLimits("pod-1", "500Mi", "") + threshold := &v1.ResourceThresholdSpec{ + MemoryPercent: &v1.ResourcePercentThreshold{Upper: intPtr(80)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + trigger.run(context.Background()) // within cooldown window + + Expect(mock.calls).To(HaveLen(1)) + }) + + // --- no limit --- + + It("skips containers without a resource limit", func() { + serverResp = podMetricsResponse{ + Containers: []containerMetricsItem{ + {Name: "app", Usage: map[string]string{"memory": "420Mi"}}, + }, + } + pod := runningPodWithLimits("pod-1", "", "") // no limits + threshold := &v1.ResourceThresholdSpec{ + MemoryPercent: &v1.ResourcePercentThreshold{Upper: intPtr(80)}, + } + trigger := buildResourceTrigger(mock, server.URL, threshold, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) +}) From 5700385aee9bd27eeb4017b9598d120a20b0f7dd Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Mon, 1 Jun 2026 11:43:40 +0530 Subject: [PATCH 15/15] feat(controller): add Kubernetes event-based checkpoint trigger Signed-off-by: Rupam-It --- api/v1/checkpointschedule_types.go | 2 +- config/rbac/role.yaml | 8 + .../checkpointschedule_controller.go | 7 + internal/controller/event_trigger.go | 255 ++++++++++++++++++ internal/controller/event_trigger_test.go | 204 ++++++++++++++ 5 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 internal/controller/event_trigger.go create mode 100644 internal/controller/event_trigger_test.go diff --git a/api/v1/checkpointschedule_types.go b/api/v1/checkpointschedule_types.go index 74c19aa2..7accc7ac 100644 --- a/api/v1/checkpointschedule_types.go +++ b/api/v1/checkpointschedule_types.go @@ -24,7 +24,7 @@ type CheckpointIntent string const ( Backup CheckpointIntent = "Backup" - PreEviction CheckpointIntent = "PreEviction" + PodDisruption CheckpointIntent = "PodDisruption" ResourcePressure CheckpointIntent = "ResourcePressure" Manual CheckpointIntent = "Manual" ) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9a548088..f7a237e2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -12,6 +12,14 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch - apiGroups: - "" resources: diff --git a/internal/controller/checkpointschedule_controller.go b/internal/controller/checkpointschedule_controller.go index ac9132bd..94707b58 100644 --- a/internal/controller/checkpointschedule_controller.go +++ b/internal/controller/checkpointschedule_controller.go @@ -33,6 +33,7 @@ import ( //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/status,verbs=get;update;patch //+kubebuilder:rbac:groups=criu.org,resources=checkpointschedules/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch +//+kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch //+kubebuilder:rbac:groups="",resources=nodes/proxy,verbs=get;create //+kubebuilder:rbac:groups=metrics.k8s.io,resources=pods,verbs=get;list @@ -118,6 +119,12 @@ func (r *CheckpointScheduleReconciler) Reconcile( triggers = append(triggers, t) } + if len(schedule.Spec.Triggers.OnKubernetesEvents) > 0 { + t := NewEventTrigger(r.Client, creator, schedule) + t.Start(ctx) + triggers = append(triggers, t) + } + r.mu.Lock() if r.activeTriggers == nil { r.activeTriggers = make(map[string][]Stoppable) diff --git a/internal/controller/event_trigger.go b/internal/controller/event_trigger.go new file mode 100644 index 00000000..88d2044c --- /dev/null +++ b/internal/controller/event_trigger.go @@ -0,0 +1,255 @@ +package controller + +import ( + "context" + "sync" + "time" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const eventPollInterval = 5 * time.Second + +// KubernetesEvent values used in spec.triggers.onKubernetesEvents. +const ( + EventNodeDrain = "NodeDrain" + EventPodEviction = "PodEviction" + // EventPreemption requires Kubernetes 1.26+ (DisruptionTarget pod condition). + EventPreemption = "Preemption" +) + +// disruptionTargetCondition is the pod condition type set by the scheduler when +// a pod is about to be preempted. Introduced in Kubernetes 1.26. +const ( + disruptionTargetCondition = "DisruptionTarget" + preemptingEvictorReason = "PreemptingEvictor" +) + +type EventTrigger struct { + client client.Client + creator Checkpointer + schedule *v1.CheckpointSchedule + stopCh chan struct{} + + watchNodeDrain bool + watchPodEviction bool + watchPreemption bool + + mu sync.Mutex + seenNodes map[string]bool // node name → drain already checkpointed + seenPods map[string]bool // "ns/name" → eviction/preemption already checkpointed +} + +func NewEventTrigger(c client.Client, creator Checkpointer, schedule *v1.CheckpointSchedule) *EventTrigger { + et := &EventTrigger{ + client: c, + creator: creator, + schedule: schedule, + stopCh: make(chan struct{}), + seenNodes: make(map[string]bool), + seenPods: make(map[string]bool), + } + for _, e := range schedule.Spec.Triggers.OnKubernetesEvents { + switch e { + case EventNodeDrain: + et.watchNodeDrain = true + case EventPodEviction: + et.watchPodEviction = true + case EventPreemption: + et.watchPreemption = true + } + } + return et +} + +func (et *EventTrigger) Start(ctx context.Context) { + logger := log.FromContext(ctx) + logger.Info("event trigger started", + "nodeDrain", et.watchNodeDrain, + "podEviction", et.watchPodEviction, + "preemption", et.watchPreemption, + ) + + go func() { + ticker := time.NewTicker(eventPollInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + et.run(ctx) + case <-et.stopCh: + return + case <-ctx.Done(): + return + } + } + }() +} + +func (et *EventTrigger) Stop() { + close(et.stopCh) +} + +func (et *EventTrigger) run(ctx context.Context) { + logger := log.FromContext(ctx) + + pods, err := getMatchingPods(ctx, et.client, et.schedule.Spec.Namespace, &et.schedule.Spec.Selector) + if err != nil { + logger.Error(err, "event trigger: failed to list pods") + return + } + + // clean up seenPods entries for pods that no longer exist + live := make(map[string]bool, len(pods)) + for _, p := range pods { + live[p.Namespace+"/"+p.Name] = true + } + et.mu.Lock() + for key := range et.seenPods { + if !live[key] { + delete(et.seenPods, key) + } + } + et.mu.Unlock() + + if et.watchNodeDrain { + et.handleNodeDrain(ctx, pods) + } + + for i := range pods { + pod := &pods[i] + key := pod.Namespace + "/" + pod.Name + + if et.watchPodEviction && pod.DeletionTimestamp != nil { + et.checkpointOnce(ctx, pod, key, "pod eviction") + } + + if et.watchPreemption && podIsPreempted(pod) { + et.checkpointOnce(ctx, pod, key, "preemption") + } + } +} + +func (et *EventTrigger) handleNodeDrain(ctx context.Context, pods []corev1.Pod) { + logger := log.FromContext(ctx) + + // collect the set of node names that host matching pods + podNodeNames := make(map[string]bool, len(pods)) + for _, p := range pods { + if p.Spec.NodeName != "" { + podNodeNames[p.Spec.NodeName] = true + } + } + + nodeList := &corev1.NodeList{} + if err := et.client.List(ctx, nodeList); err != nil { + logger.Error(err, "event trigger: failed to list nodes") + return + } + + // which of our nodes are currently draining? + drainingNow := make(map[string]bool) + for _, node := range nodeList.Items { + if node.Spec.Unschedulable && podNodeNames[node.Name] { + drainingNow[node.Name] = true + } + } + + // evict nodes from seenNodes once they are no longer draining + // so the next drain cycle is caught fresh + et.mu.Lock() + for nodeName := range et.seenNodes { + if !drainingNow[nodeName] { + delete(et.seenNodes, nodeName) + } + } + et.mu.Unlock() + + for nodeName := range drainingNow { + et.mu.Lock() + already := et.seenNodes[nodeName] + et.mu.Unlock() + if already { + continue + } + + logger.Info("event trigger: node drain detected", "node", nodeName) + + fired := false + for i := range pods { + pod := &pods[i] + if pod.Spec.NodeName != nodeName { + continue + } + et.checkpointPodContainers(ctx, pod, "node drain") + fired = true + } + + if fired { + et.mu.Lock() + et.seenNodes[nodeName] = true + et.mu.Unlock() + } + } +} + +// checkpointOnce checkpoints a pod exactly once per disruption event. +func (et *EventTrigger) checkpointOnce(ctx context.Context, pod *corev1.Pod, key, reason string) { + et.mu.Lock() + already := et.seenPods[key] + et.mu.Unlock() + if already { + return + } + + log.FromContext(ctx).Info("event trigger: pod disruption detected", + "pod", pod.Name, "reason", reason, "intent", string(v1.PodDisruption)) + + et.checkpointPodContainers(ctx, pod, reason) + + et.mu.Lock() + et.seenPods[key] = true + et.mu.Unlock() +} + +// checkpointPodContainers checkpoints all (or configured) containers in a pod. +func (et *EventTrigger) checkpointPodContainers(ctx context.Context, pod *corev1.Pod, reason string) { + logger := log.FromContext(ctx) + + containerSet := make(map[string]struct{}, len(et.schedule.Spec.ContainerNames)) + for _, name := range et.schedule.Spec.ContainerNames { + containerSet[name] = struct{}{} + } + + for _, c := range pod.Spec.Containers { + if len(containerSet) > 0 { + if _, ok := containerSet[c.Name]; !ok { + continue + } + } + if err := et.creator.createCheckpoint(ctx, pod.Namespace, pod.Name, c.Name, pod.Spec.NodeName); err != nil { + logger.Error(err, "event trigger: checkpoint failed", + "pod", pod.Name, "container", c.Name, "reason", reason) + } else { + logger.Info("event trigger: checkpoint created", + "pod", pod.Name, "container", c.Name, + "reason", reason, "intent", string(v1.PodDisruption)) + } + } +} + +// podIsPreempted returns true when the pod carries the DisruptionTarget condition +// with reason PreemptingEvictor. This condition is available since Kubernetes 1.26. +func podIsPreempted(pod *corev1.Pod) bool { + for _, cond := range pod.Status.Conditions { + if cond.Type == disruptionTargetCondition && + cond.Reason == preemptingEvictorReason && + cond.Status == corev1.ConditionTrue { + return true + } + } + return false +} \ No newline at end of file diff --git a/internal/controller/event_trigger_test.go b/internal/controller/event_trigger_test.go new file mode 100644 index 00000000..65b028a5 --- /dev/null +++ b/internal/controller/event_trigger_test.go @@ -0,0 +1,204 @@ +package controller + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/checkpoint-restore/checkpoint-restore-operator/api/v1" +) + +func buildEventTrigger(mock *mockCheckpointer, events []string, nodes []*corev1.Node, pods ...*corev1.Pod) *EventTrigger { + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + for _, n := range nodes { + builder = builder.WithObjects(n) + } + for _, p := range pods { + builder = builder.WithObjects(p) + } + sched := &v1.CheckpointSchedule{ + Spec: v1.CheckpointScheduleSpec{ + Namespace: "default", + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}}, + Triggers: v1.TriggersSpec{OnKubernetesEvents: events}, + }, + } + return NewEventTrigger(builder.Build(), mock, sched) +} + +func testNode(name string, unschedulable bool) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.NodeSpec{Unschedulable: unschedulable}, + } +} + +func testPod(name, nodeName string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{ + NodeName: nodeName, + Containers: []corev1.Container{{Name: "app"}}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } +} + +var _ = Describe("EventTrigger — NodeDrain", func() { + var mock *mockCheckpointer + + BeforeEach(func() { mock = &mockCheckpointer{} }) + + It("checkpoints pods on an unschedulable node", func() { + node := testNode("node-1", true) + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventNodeDrain}, []*corev1.Node{node}, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].pod).To(Equal("pod-1")) + }) + + It("does not checkpoint pods on a schedulable node", func() { + node := testNode("node-1", false) + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventNodeDrain}, []*corev1.Node{node}, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) + + It("checkpoints each drain only once until node becomes schedulable again", func() { + node := testNode("node-1", true) + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventNodeDrain}, []*corev1.Node{node}, pod) + + trigger.run(context.Background()) + trigger.run(context.Background()) // second run — node still draining + + Expect(mock.calls).To(HaveLen(1)) + }) + + It("re-checkpoints after a node finishes draining and drains again", func() { + node := testNode("node-1", true) + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventNodeDrain}, []*corev1.Node{node}, pod) + + trigger.run(context.Background()) + Expect(mock.calls).To(HaveLen(1)) + + // simulate node becoming schedulable (drain complete) + trigger.mu.Lock() + delete(trigger.seenNodes, "node-1") + trigger.mu.Unlock() + + trigger.run(context.Background()) + Expect(mock.calls).To(HaveLen(2)) + }) +}) + +var _ = Describe("EventTrigger — PodEviction", func() { + var mock *mockCheckpointer + + BeforeEach(func() { mock = &mockCheckpointer{} }) + + It("checkpoints a pod with a deletion timestamp", func() { + now := metav1.NewTime(time.Now()) + pod := testPod("pod-1", "node-1") + pod.Finalizers = []string{"test/keep-alive"} // required by fake client to accept DeletionTimestamp + pod.DeletionTimestamp = &now + + trigger := buildEventTrigger(mock, []string{EventPodEviction}, nil, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].pod).To(Equal("pod-1")) + }) + + It("does not checkpoint a running pod without a deletion timestamp", func() { + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventPodEviction}, nil, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) + + It("checkpoints an evicting pod only once", func() { + now := metav1.NewTime(time.Now()) + pod := testPod("pod-1", "node-1") + pod.Finalizers = []string{"test/keep-alive"} + pod.DeletionTimestamp = &now + + trigger := buildEventTrigger(mock, []string{EventPodEviction}, nil, pod) + trigger.run(context.Background()) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + }) +}) + +var _ = Describe("EventTrigger — Preemption (k8s 1.26+)", func() { + var mock *mockCheckpointer + + BeforeEach(func() { mock = &mockCheckpointer{} }) + + It("checkpoints a pod with DisruptionTarget/PreemptingEvictor condition", func() { + pod := testPod("pod-1", "node-1") + pod.Status.Conditions = []corev1.PodCondition{{ + Type: disruptionTargetCondition, + Reason: preemptingEvictorReason, + Status: corev1.ConditionTrue, + }} + + trigger := buildEventTrigger(mock, []string{EventPreemption}, nil, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + Expect(mock.calls[0].pod).To(Equal("pod-1")) + }) + + It("does not checkpoint a pod without the preemption condition", func() { + pod := testPod("pod-1", "node-1") + trigger := buildEventTrigger(mock, []string{EventPreemption}, nil, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) + + It("checkpoints a preempted pod only once", func() { + pod := testPod("pod-1", "node-1") + pod.Status.Conditions = []corev1.PodCondition{{ + Type: disruptionTargetCondition, + Reason: preemptingEvictorReason, + Status: corev1.ConditionTrue, + }} + + trigger := buildEventTrigger(mock, []string{EventPreemption}, nil, pod) + trigger.run(context.Background()) + trigger.run(context.Background()) + + Expect(mock.calls).To(HaveLen(1)) + }) +}) + +var _ = Describe("EventTrigger — event filtering", func() { + It("does not fire NodeDrain when only PodEviction is configured", func() { + mock := &mockCheckpointer{} + node := testNode("node-1", true) + pod := testPod("pod-1", "node-1") + + trigger := buildEventTrigger(mock, []string{EventPodEviction}, []*corev1.Node{node}, pod) + trigger.run(context.Background()) + + Expect(mock.calls).To(BeEmpty()) + }) +}) \ No newline at end of file