diff --git a/apis/kustomize/doc.go b/apis/kustomize/doc.go index 14782db5..8e73c516 100644 --- a/apis/kustomize/doc.go +++ b/apis/kustomize/doc.go @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package kustomize contains a selective set of Kustomize APIs for use by -// toolkit components. +// Package kustomize contains a selective set of Kustomize API types for use by GitOps Toolkit components. // +kubebuilder:object:generate=true package kustomize diff --git a/apis/kustomize/kustomize_types.go b/apis/kustomize/kustomize_types.go index 1c929845..070f7461 100644 --- a/apis/kustomize/kustomize_types.go +++ b/apis/kustomize/kustomize_types.go @@ -20,8 +20,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) -// Image contains an image name, a new name, a new tag or digest, -// which will replace the original name and tag. +// Image contains an image name, a new name, a new tag or digest, which will replace the original name and tag. type Image struct { // Name is a tag-less image name. // +required @@ -41,19 +40,17 @@ type Image struct { Digest string `json:"digest,omitempty"` } -// Selector specifies a set of resources. -// Any resource that matches intersection of all conditions is included in this set. +// Selector specifies a set of resources. Any resource that matches intersection of all conditions is included in this +// set. type Selector struct { // Group is the API group to select resources from. - // Together with Version and Kind it is capable of unambiguously - // identifying and/or selecting resources. + // Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. // https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md // +optional Group string `json:"group,omitempty"` // Version of the API Group to select resources from. - // Together with Group and Kind it is capable of unambiguously - // identifying and/or selecting resources. + // Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. // https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md // +optional Version string `json:"version,omitempty"` @@ -86,44 +83,51 @@ type Selector struct { LabelSelector string `json:"labelSelector,omitempty"` } -// Patch contains either a StrategicMerge or a JSON6902 patch, either a file or inline, -// and the target the patch should be applied to. +// Patch contains either a StrategicMerge or a JSON6902 patch, either a file or inline, and the target the patch should +// be applied to. type Patch struct { - // Patch contains the JSON6902 patch document with an array of - // operation objects. + // Patch contains the JSON6902 patch document with an array of operation objects. // +required Patch string `json:"patch,omitempty"` - // Target points to the resources that the patch document should - // be applied to. + // Target points to the resources that the patch document should be applied to. // +optional Target Selector `json:"target,omitempty"` } // JSON6902 is a JSON6902 operation object. -// https://tools.ietf.org/html/rfc6902#section-4 +// https://datatracker.ietf.org/doc/html/rfc6902#section-4 type JSON6902 struct { + // Op indicates the operation to perform. Its value MUST be one of "add", "remove", "replace", "move", "copy", or + // "test". + // https://datatracker.ietf.org/doc/html/rfc6902#section-4 // +kubebuilder:validation:Enum=test;remove;add;replace;move;copy // +required Op string `json:"op"` + + // Path contains the JSON-pointer value that references a location within the target document where the operation + // is performed. The meaning of the value depends on the value of Op. // +required Path string `json:"path"` + + // From contains a JSON-pointer value that references a location within the target document where the operation is + // performed. The meaning of the value depends on the value of Op, and is NOT taken into account by all operations. // +optional From string `json:"from,omitempty"` + + // Value contains a valid JSON structure. The meaning of the value depends on the value of Op, and is NOT taken into + // account by all operations. // +optional Value *apiextensionsv1.JSON `json:"value,omitempty"` } -// JSON6902Patch contains a JSON6902 patch and the target the patch -// should be applied to. +// JSON6902Patch contains a JSON6902 patch and the target the patch should be applied to. type JSON6902Patch struct { - // Patch contains the JSON6902 patch document with an array of - // operation objects. + // Patch contains the JSON6902 patch document with an array of operation objects. // +required Patch []JSON6902 `json:"patch"` - // Target points to the resources that the patch document should - // be applied to. + // Target points to the resources that the patch document should be applied to. // +required Target Selector `json:"target"` } diff --git a/apis/meta/annotations.go b/apis/meta/annotations.go index 319bfecf..4fd37c9c 100644 --- a/apis/meta/annotations.go +++ b/apis/meta/annotations.go @@ -21,19 +21,19 @@ const ( // outside of the defined schedule. Despite the name, the value is not // interpreted as a timestamp, and any change in value shall trigger a // reconciliation. - // DEPRECATED: has been replaced by ReconcileRequestAnnotation. + // DEPRECATED: has been replaced by ReconcileRequestAnnotation. For + // backward-compatibility, use ReconcileAnnotationValue, which will account for + // both annotations. ReconcileAtAnnotation string = "fluxcd.io/reconcileAt" - // ReconcileRequestAnnotation is the new ReconcileAtAnnotation, - // with a better name. For backward-compatibility, use - // ReconcileAnnotationValue, which will account for both - // annotations. + // ReconcileRequestAnnotation is the annotation used for triggering a reconciliation + // outside of a defined schedule. The value is interpreted as a token, and any change + // in value SHOULD trigger a reconciliation. ReconcileRequestAnnotation string = "reconcile.fluxcd.io/requestedAt" ) -// ReconcileAnnotationValue returns a value for the reconciliation -// request annotations, which can be used to detect changes; and, a -// boolean indicating whether either annotation was set. +// ReconcileAnnotationValue returns a value for the reconciliation request annotations, which can be used to detect +// changes; and, a boolean indicating whether either annotation was set. func ReconcileAnnotationValue(annotations map[string]string) (string, bool) { reconcileAt, ok1 := annotations[ReconcileAtAnnotation] requestedAt, ok2 := annotations[ReconcileRequestAnnotation] @@ -49,27 +49,40 @@ func ReconcileAnnotationValue(annotations map[string]string) (string, bool) { return reconcileAt + requestedAt, ok1 || ok2 } -// ReconcileRequestStatus is a struct to embed in the status type, so -// that all types using the mechanism have the same field. Use it like -// this: +// ReconcileRequestStatus is a struct to embed in a status type, so that all types using the mechanism have the same +// field. Use it like this: // -// ``` -// type WhateverStatus struct { -// meta.ReconcileRequestStatus `json:",inline"` -// // other status fields... -// } -// ``` +// type FooStatus struct { +// meta.ReconcileRequestStatus `json:",inline"` +// // other status fields... +// } type ReconcileRequestStatus struct { // LastHandledReconcileAt holds the value of the most recent - // reconcile request value, so a change can be detected. + // reconcile request value, so a change of the annotation value + // can be detected. // +optional LastHandledReconcileAt string `json:"lastHandledReconcileAt,omitempty"` } -func (rs ReconcileRequestStatus) GetLastHandledReconcileRequest() string { - return rs.LastHandledReconcileAt +// GetLastHandledReconcileRequest returns the most recent reconcile request value from the ReconcileRequestStatus. +func (in ReconcileRequestStatus) GetLastHandledReconcileRequest() string { + return in.LastHandledReconcileAt } -func (rs *ReconcileRequestStatus) SetLastHandledReconcileRequest(token string) { - rs.LastHandledReconcileAt = token +// SetLastHandledReconcileRequest sets the most recent reconcile request value in the ReconcileRequestStatus. +func (in *ReconcileRequestStatus) SetLastHandledReconcileRequest(token string) { + in.LastHandledReconcileAt = token +} + +// StatusWithHandledReconcileRequest describes a status type which holds the value of the most recent +// ReconcileAnnotationValue. +// +k8s:deepcopy-gen=false +type StatusWithHandledReconcileRequest interface { + GetLastHandledReconcileRequest() string +} + +// StatusWithHandledReconcileRequestSetter describes a status with a setter for the most ReconcileAnnotationValue. +// +k8s:deepcopy-gen=false +type StatusWithHandledReconcileRequestSetter interface { + SetLastHandledReconcileRequest(token string) } diff --git a/apis/meta/conditions.go b/apis/meta/conditions.go index e78cc3b1..726890f6 100644 --- a/apis/meta/conditions.go +++ b/apis/meta/conditions.go @@ -17,62 +17,99 @@ limitations under the License. package meta import ( - apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// These constants define generic Condition types to be used by GitOps Toolkit components. +// +// The ReadyCondition SHOULD be implemented by all components' Kubernetes resources to indicate they have been fully +// reconciled by their respective reconciler. This MAY suffice for simple resources, e.g. a resource that just declares +// state once and is not expected to receive any updates afterwards. +// +// For Kubernetes resources that are expected to receive spec updates over time, take a longer time to reconcile, or +// deal with more complex logic in which for example a finite error state can be observed, it is RECOMMENDED to +// implement the StalledCondition and ReconcilingCondition. +// +// By doing this, observers making use of kstatus to determine the current state of the resource will have a better +// experience while they are e.g. waiting for a change to be reconciled, and will be able to stop waiting for a change +// if a StalledCondition is observed, without having to rely on a timeout. +// +// For more information on kstatus, see: +// https://github.com/kubernetes-sigs/cli-utils/blob/v0.25.0/pkg/kstatus/README.md const ( - // ReadyCondition is the name of the Ready condition implemented by all toolkit - // resources. + // ReadyCondition indicates the resource is ready and fully reconciled. + // If the Condition is False, the resource SHOULD be considered to be in the process of reconciling and not a + // representation of actual state. ReadyCondition string = "Ready" - // StalledCondition is the name of the Stalled kstatus condition + // StalledCondition indicates the reconciliation of the resource has stalled, e.g. because the controller has + // encountered an error during the reconcile process or it has made insufficient progress (timeout). + // The Condition adheres to an "abnormal-true" polarity pattern, and MUST only be present on the resource if the + // Condition is True. + // For more information about polarity patterns, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties StalledCondition string = "Stalled" - // ReconcilingCondition is the name of the Reconciling kstatus condition + // ReconcilingCondition indicates the controller is currently working on reconciling the latest changes. This MAY be + // True for multiple reconciliation attempts, e.g. when an transient error occurred. + // The Condition adheres to an "abnormal-true" polarity pattern, and MUST only be present on the resource if the + // Condition is True. + // For more information about polarity patterns, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties ReconcilingCondition string = "Reconciling" ) +// These constants define generic Condition reasons to be used by GitOps Toolkit components. +// +// Making use of a generic Reason is RECOMMENDED whenever it can be applied to a Condition in which it provides +// sufficient context together with the type to summarize the meaning of the Condition cause. +// +// Where any of the generic Condition reasons does not suffice, GitOps Toolkit components can introduce new reasons to +// their API specification, or use an arbitrary PascalCase string when setting the Condition. +// Declaration of domain common Condition reasons in the API specification is RECOMMENDED, as it eases observations +// for user and computer. +// +// For more information on Condition reason conventions, see: +// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties const ( - // ReconciliationSucceededReason represents the fact that the reconciliation of - // a toolkit resource has succeeded. - ReconciliationSucceededReason string = "ReconciliationSucceeded" - - // ReconciliationFailedReason represents the fact that the reconciliation of a - // toolkit resource has failed. - ReconciliationFailedReason string = "ReconciliationFailed" - - // ProgressingReason represents the fact that the reconciliation of a toolkit - // resource is underway. + // SucceededReason indicates a condition or event observed a success, for example when declared desired state + // matches actual state, or a performed action succeeded. + // + // More information about the reason of success MAY be available as additional metadata in an attached message. + SucceededReason string = "Succeeded" + + // FailedReason indicates a condition or event observed a failure, for example when declared state does not match + // actual state, or a performed action failed. + // + // More information about the reason of failure MAY be available as additional metadata in an attached message. + FailedReason string = "Failed" + + // ProgressingReason indicates a condition or event observed progression, for example when the reconciliation of a + // resource or an action has started. + // + // When this reason is given, other conditions and types MAY no longer be considered as an up-to-date observation. + // Producers of the specific condition type or event SHOULD provide more information about the expectations and + // precise meaning in their API specification. + // + // More information about the reason or the current state of the progression MAY be available as additional metadata + // in an attached message. ProgressingReason string = "Progressing" - // DependencyNotReadyReason represents the fact that one of the toolkit resource - // dependencies is not ready. - DependencyNotReadyReason string = "DependencyNotReady" - - // SuspendedReason represents the fact that the reconciliation of a toolkit - // resource is suspended. + // SuspendedReason indicates a condition or event has observed a suspension, for + // example because a resource has been suspended, or a dependency is. SuspendedReason string = "Suspended" ) -// ObjectWithStatusConditions is an interface that describes kubernetes resource -// type structs with Status Conditions +// ObjectWithConditions describes a Kubernetes resource object with status conditions. // +k8s:deepcopy-gen=false -type ObjectWithStatusConditions interface { - GetStatusConditions() *[]metav1.Condition +type ObjectWithConditions interface { + // GetConditions returns a slice of metav1.Condition + GetConditions() []metav1.Condition } -// SetResourceCondition sets the given condition with the given status, -// reason and message on a resource. -func SetResourceCondition(obj ObjectWithStatusConditions, condition string, status metav1.ConditionStatus, reason, message string) { - conditions := obj.GetStatusConditions() - - newCondition := metav1.Condition{ - Type: condition, - Status: status, - Reason: reason, - Message: message, - } - - apimeta.SetStatusCondition(conditions, newCondition) +// ObjectWithConditionsSetter describes a Kubernetes resource object with a status conditions setter. +// +k8s:deepcopy-gen=false +type ObjectWithConditionsSetter interface { + // SetConditions sets the status conditions on the object + SetConditions([]metav1.Condition) } diff --git a/apis/meta/dependencies.go b/apis/meta/dependencies.go new file mode 100644 index 00000000..a6a3b3db --- /dev/null +++ b/apis/meta/dependencies.go @@ -0,0 +1,24 @@ +/* +Copyright 2021 The Flux 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 meta + +// ObjectWithDependencies describes a Kubernetes resource object with dependencies. +// +k8s:deepcopy-gen=false +type ObjectWithDependencies interface { + // GetDependsOn returns a NamespacedObjectReference list the object depends on. + GetDependsOn() []NamespacedObjectReference +} diff --git a/apis/meta/doc.go b/apis/meta/doc.go index 728a300f..6222744a 100644 --- a/apis/meta/doc.go +++ b/apis/meta/doc.go @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package meta contains the generic metadata APIs for use by -// toolkit components. +// Package meta contains the generic metadata APIs for use by GitOps Toolkit components. +// +// It is intended only to help adhere to Kubernetes API conventions, utility integrations, and Flux project considered +// best practices. It may therefore be suitable for usage by Kubernetes resources with no relationship to the GitOps +// Toolkit. // +kubebuilder:object:generate=true package meta diff --git a/apis/meta/reference_types.go b/apis/meta/reference_types.go index e8c0e63f..d6e32f6f 100644 --- a/apis/meta/reference_types.go +++ b/apis/meta/reference_types.go @@ -16,45 +16,41 @@ limitations under the License. package meta -// LocalObjectReference contains enough information to let you locate -// the referenced object inside the same namespace +// LocalObjectReference contains enough information to locate the referenced Kubernetes resource object. type LocalObjectReference struct { - // Name of the referent + // Name of the referent. // +required Name string `json:"name"` } -// NamespacedObjectReference contains enough information to let you locate -// the referenced object in any namespace +// NamespacedObjectReference contains enough information to locate the referenced Kubernetes resource object in any +// namespace. type NamespacedObjectReference struct { - // Name of the referent + // Name of the referent. // +required Name string `json:"name"` - // Namespace of the referent, - // when not specified it acts as LocalObjectReference + // Namespace of the referent, when not specified it acts as LocalObjectReference. // +optional Namespace string `json:"namespace,omitempty"` } -// NamespacedObjectKindReference contains enough information to let you locate -// the typed referenced object in any namespace +// NamespacedObjectKindReference contains enough information to locate the typed referenced Kubernetes resource object +// in any namespace. type NamespacedObjectKindReference struct { - // API version of the referent, - // if not specified the Kubernetes preferred version will be used + // API version of the referent, if not specified the Kubernetes preferred version will be used. // +optional APIVersion string `json:"apiVersion,omitempty"` - // Kind of the referent + // Kind of the referent. // +required Kind string `json:"kind"` - // Name of the referent + // Name of the referent. // +required Name string `json:"name"` - // Namespace of the referent, - // when not specified it acts as LocalObjectReference + // Namespace of the referent, when not specified it acts as LocalObjectReference. // +optional Namespace string `json:"namespace,omitempty"` } diff --git a/runtime/client/client.go b/runtime/client/client.go index d13241b0..25e4e21f 100644 --- a/runtime/client/client.go +++ b/runtime/client/client.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Flux 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 client import ( @@ -11,19 +27,33 @@ const ( flagBurst = "kube-api-burst" ) -// Options contains the configuration options for the Kubernetes client. +// Options contains the runtime configuration for a Kubernetes client. +// +// The struct can be used in the main.go file of your controller by binding it to the main flag set, and then utilizing +// the configured options later: +// +// func main() { +// var ( +// // other controller specific configuration variables +// clientOptions client.Options +// ) +// +// // Bind the options to the main flag set, and parse it +// clientOptions.BindFlags(flag.CommandLine) +// flag.Parse() +// +// // Get a runtime Kubernetes client configuration with the options set +// restConfig := client.GetConfigOrDie(clientOptions) +// } type Options struct { - // QPS indicates the maximum queries-per-second of - //requests sent to to the Kubernetes API, defaults to 20. + // QPS indicates the maximum queries-per-second of requests sent to to the Kubernetes API, defaults to 20. QPS float32 - // Burst indicates the maximum burst queries-per-second of - // requests sent to the Kubernetes API, defaults to 50. + // Burst indicates the maximum burst queries-per-second of requests sent to the Kubernetes API, defaults to 50. Burst int } -// BindFlags will parse the given flagset for Kubernetes client option flags and -// set the Options accordingly. +// BindFlags will parse the given pflag.FlagSet for Kubernetes client option flags and set the Options accordingly. func (o *Options) BindFlags(fs *pflag.FlagSet) { fs.Float32Var(&o.QPS, flagQPS, 20.0, "The maximum queries-per-second of requests sent to the Kubernetes API.") @@ -31,10 +61,10 @@ func (o *Options) BindFlags(fs *pflag.FlagSet) { "The maximum burst queries-per-second of requests sent to the Kubernetes API.") } -// GetConfigOrDie wraps ctrl.GetConfigOrDie and sets the QPS and Bust options +// GetConfigOrDie wraps ctrl.GetConfigOrDie and sets the configured Options, returning the modified rest.Config. func GetConfigOrDie(opts Options) *rest.Config { - restConfig := ctrl.GetConfigOrDie() - restConfig.QPS = opts.QPS - restConfig.Burst = opts.Burst - return restConfig + config := ctrl.GetConfigOrDie() + config.QPS = opts.QPS + config.Burst = opts.Burst + return config } diff --git a/runtime/client/doc.go b/runtime/client/doc.go new file mode 100644 index 00000000..16ce9080 --- /dev/null +++ b/runtime/client/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Flux 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 client provides runtime configuration options for a Kubernetes client, making it easier to consistently have +// the same configuration options and flags across GitOps Toolkit components. +package client diff --git a/runtime/conditions/doc.go b/runtime/conditions/doc.go new file mode 100644 index 00000000..9dfbc882 --- /dev/null +++ b/runtime/conditions/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2021 The Flux 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 conditions provides utilities for manipulating the status conditions of Kubernetes resource objects that +// implement the Getter and/or Setter interfaces. +// +// Usage of this package within GitOps Toolkit components working with conditions is RECOMMENDED, as it provides a wide +// range of helpers to work around common reconciler problems, like setting a Condition status based on a +// summarization of other conditions, producing an aggregate Condition based on the conditions of a list of Kubernetes +// resources objects, recognition of negative polarity or "abnormal-true" conditions, etc. +package conditions diff --git a/runtime/conditions/fake_test.go b/runtime/conditions/fake_test.go new file mode 100644 index 00000000..da631de5 --- /dev/null +++ b/runtime/conditions/fake_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2021 The Flux 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 conditions + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const fakeGroupName = "fake" + +// fakeSchemeGroupVersion is group version used to register the fake object +var fakeSchemeGroupVersion = schema.GroupVersion{Group: fakeGroupName, Version: "v1"} + +// fake is a mock struct that adheres to the minimal requirements to +// work with the condition helpers, by implementing client.Object. +type fake struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Status fakeStatus `json:"status,omitempty"` +} + +type fakeStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +func (f fake) GetConditions() []metav1.Condition { + return f.Status.Conditions +} + +func (f *fake) SetConditions(conditions []metav1.Condition) { + f.Status.Conditions = conditions +} + +func (f *fake) DeepCopyInto(out *fake) { + *out = *f + out.TypeMeta = f.TypeMeta + f.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + f.Status.DeepCopyInto(&out.Status) +} + +func (f *fake) DeepCopy() *fake { + if f == nil { + return nil + } + out := new(fake) + f.DeepCopyInto(out) + return out +} + +func (f *fake) DeepCopyObject() runtime.Object { + if c := f.DeepCopy(); c != nil { + return c + } + return nil +} + +func (f *fakeStatus) DeepCopyInto(out *fakeStatus) { + *out = *f + if f.Conditions != nil { + in, out := &f.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +func (f *fakeStatus) DeepCopy() *fakeStatus { + if f == nil { + return nil + } + out := new(fakeStatus) + f.DeepCopyInto(out) + return out +} diff --git a/runtime/conditions/getter.go b/runtime/conditions/getter.go new file mode 100644 index 00000000..b9050e2b --- /dev/null +++ b/runtime/conditions/getter.go @@ -0,0 +1,308 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/getter.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/fluxcd/pkg/apis/meta" +) + +// Getter interface defines methods that a Kubernetes resource object should implement in order to use the conditions +// package for getting conditions. +type Getter interface { + client.Object + meta.ObjectWithConditions +} + +// Get returns the condition with the given type, if the condition does not exists, it returns nil. +func Get(from Getter, t string) *metav1.Condition { + conditions := from.GetConditions() + if conditions == nil { + return nil + } + + for _, condition := range conditions { + if condition.Type == t { + return &condition + } + } + return nil +} + +// Has returns true if a condition with the given type exists. +func Has(from Getter, t string) bool { + return Get(from, t) != nil +} + +// IsTrue is true if the condition with the given type is True, otherwise it return false if the condition is not True +// or if the condition does not exist (is nil). +func IsTrue(from Getter, t string) bool { + if c := Get(from, t); c != nil { + return c.Status == metav1.ConditionTrue + } + return false +} + +// IsFalse is true if the condition with the given type is False, otherwise it return false if the condition is not +// False or if the condition does not exist (is nil). +func IsFalse(from Getter, t string) bool { + if c := Get(from, t); c != nil { + return c.Status == metav1.ConditionFalse + } + return false +} + +// IsUnknown is true if the condition with the given type is Unknown or if the condition does not exist (is nil). +func IsUnknown(from Getter, t string) bool { + if c := Get(from, t); c != nil { + return c.Status == metav1.ConditionUnknown + } + return true +} + +// GetReason returns a nil safe string of Reason for the condition with the given type. +func GetReason(from Getter, t string) string { + if c := Get(from, t); c != nil { + return c.Reason + } + return "" +} + +// GetMessage returns a nil safe string of Message for the condition with the given type. +func GetMessage(from Getter, t string) string { + if c := Get(from, t); c != nil { + return c.Message + } + return "" +} + +// GetLastTransitionTime returns the LastTransitionType or nil if the condition does not exist (is nil). +func GetLastTransitionTime(from Getter, t string) *metav1.Time { + if c := Get(from, t); c != nil { + return &c.LastTransitionTime + } + return nil +} + +// GetObservedGeneration returns a nil safe int64 of ObservedGeneration for the condition with the given type. +func GetObservedGeneration(from Getter, t string) int64 { + if c := Get(from, t); c != nil { + return c.ObservedGeneration + } + return 0 +} + +// summary returns a condition with the summary of all the conditions existing on an object. If the object does not have +// other conditions, no summary condition is generated. +func summary(from Getter, t string, options ...MergeOption) *metav1.Condition { + conditions := from.GetConditions() + + mergeOpt := &mergeOptions{} + for _, o := range options { + o(mergeOpt) + } + + // Identifies the conditions in scope for the Summary by taking all the existing conditions except t, + // or, if a list of conditions types is specified, only the conditions the condition in that list. + conditionsInScope := make([]localizedCondition, 0, len(conditions)) + for i := range conditions { + c := conditions[i] + if c.Type == t { + continue + } + + if mergeOpt.conditionTypes != nil { + found := false + for _, tt := range mergeOpt.conditionTypes { + if c.Type == tt { + found = true + break + } + } + if !found { + continue + } + } + + conditionsInScope = append(conditionsInScope, localizedCondition{ + Condition: &c, + Getter: from, + }) + } + + // If it is required to add a step counter only if a subset of condition exists, check if the conditions + // in scope are included in this subset or not. + if mergeOpt.addStepCounterIfOnlyConditionTypes != nil { + for _, c := range conditionsInScope { + found := false + for _, tt := range mergeOpt.addStepCounterIfOnlyConditionTypes { + if c.Type == tt { + found = true + break + } + } + if !found { + mergeOpt.addStepCounter = false + break + } + } + } + + // If it is required to add a step counter, determine the total number of conditions defaulting + // to the selected conditions or, if defined, to the total number of conditions type to be considered. + if mergeOpt.addStepCounter { + mergeOpt.stepCounter = len(conditionsInScope) + if mergeOpt.conditionTypes != nil { + mergeOpt.stepCounter = len(mergeOpt.conditionTypes) + } + if mergeOpt.addStepCounterIfOnlyConditionTypes != nil { + mergeOpt.stepCounter = len(mergeOpt.addStepCounterIfOnlyConditionTypes) + } + } + + return merge(conditionsInScope, t, mergeOpt) +} + +// mirrorOptions allows to set options for the mirror operation. +type mirrorOptions struct { + fallbackTo *bool + fallbackReason string + fallbackMessage string +} + +// MirrorOptions defines an option for mirroring conditions. +type MirrorOptions func(*mirrorOptions) + +// WithFallbackValue specify a fallback value to use in case the mirrored condition does not exists; in case the +// fallbackValue is false, given values for reason and message will be used. +func WithFallbackValue(fallbackValue bool, reason string, message string) MirrorOptions { + return func(c *mirrorOptions) { + c.fallbackTo = &fallbackValue + c.fallbackReason = reason + c.fallbackMessage = message + } +} + +// mirror mirrors the Ready condition from a dependent object into the target condition; if the Ready condition does not +// exists in the source object, no target conditions is generated. +func mirror(from Getter, targetCondition string, options ...MirrorOptions) *metav1.Condition { + mirrorOpt := &mirrorOptions{} + for _, o := range options { + o(mirrorOpt) + } + + condition := Get(from, meta.ReadyCondition) + + if mirrorOpt.fallbackTo != nil && condition == nil { + switch *mirrorOpt.fallbackTo { + case true: + condition = TrueCondition(targetCondition, mirrorOpt.fallbackReason, mirrorOpt.fallbackMessage) + case false: + condition = FalseCondition(targetCondition, mirrorOpt.fallbackReason, mirrorOpt.fallbackMessage) + } + } + + if condition != nil { + condition.Type = targetCondition + } + + return condition +} + +// aggregate the conditions from a list of depending objects into the target object; the condition scope can be set +// using WithConditions; if none of the source objects have the conditions within the scope, no target condition is +// generated. +func aggregate(from []Getter, targetCondition string, options ...MergeOption) *metav1.Condition { + mergeOpt := &mergeOptions{ + stepCounter: len(from), + } + for _, o := range options { + o(mergeOpt) + } + + conditionsInScope := make([]localizedCondition, 0, len(from)) + for i := range from { + conditions := from[i].GetConditions() + for i, _ := range conditions { + c := conditions[i] + if mergeOpt.conditionTypes != nil { + found := false + for _, tt := range mergeOpt.conditionTypes { + if c.Type == tt { + found = true + break + } + } + if !found { + continue + } + } + + conditionsInScope = append(conditionsInScope, localizedCondition{ + Condition: &c, + Getter: from[i], + }) + } + } + + // If it is required to add a counter only if a subset of condition exists, check if the conditions + // in scope are included in this subset or not. + if mergeOpt.addCounterOnlyIfConditionTypes != nil { + for _, c := range conditionsInScope { + found := false + for _, tt := range mergeOpt.addCounterOnlyIfConditionTypes { + if c.Type == tt { + found = true + break + } + } + if !found { + mergeOpt.addCounter = false + break + } + } + } + + // If it is required to add a source ref only if a condition type exists, check if the conditions + // in scope are included in this subset or not. + if mergeOpt.addSourceRefIfConditionTypes != nil { + for _, c := range conditionsInScope { + found := false + for _, tt := range mergeOpt.addSourceRefIfConditionTypes { + if c.Type == tt { + found = true + break + } + } + if found { + mergeOpt.addSourceRef = true + break + } + } + } + + return merge(conditionsInScope, targetCondition, mergeOpt) +} diff --git a/runtime/conditions/getter_test.go b/runtime/conditions/getter_test.go new file mode 100644 index 00000000..21cd2280 --- /dev/null +++ b/runtime/conditions/getter_test.go @@ -0,0 +1,366 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/getter_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + + "github.com/fluxcd/pkg/apis/meta" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + nil1 *metav1.Condition + true1 = TrueCondition("true1", "reason true1", "message true1") + unknown1 = UnknownCondition("unknown1", "reason unknown1", "message unknown1") + false1 = FalseCondition("false1", "reason false1", "message false1") +) + +func TestGetAndHas(t *testing.T) { + g := NewWithT(t) + + obj := &fake{} + + g.Expect(Has(obj, "conditionBaz")).To(BeFalse()) + g.Expect(Get(obj, "conditionBaz")).To(BeNil()) + + obj.SetConditions(conditionList(TrueCondition("conditionBaz", "", ""))) + + g.Expect(Has(obj, "conditionBaz")).To(BeTrue()) + g.Expect(Get(obj, "conditionBaz")).To(HaveSameStateOf(TrueCondition("conditionBaz", "", ""))) +} + +func TestIsMethods(t *testing.T) { + g := NewWithT(t) + + false2 := false1.DeepCopy() + false2.Type = "false2" + false2.ObservedGeneration = 1 + + obj := getterWithConditions(nil1, true1, unknown1, false1, false2) + + // test isTrue + g.Expect(IsTrue(obj, "nil1")).To(BeFalse()) + g.Expect(IsTrue(obj, "true1")).To(BeTrue()) + g.Expect(IsTrue(obj, "false1")).To(BeFalse()) + g.Expect(IsTrue(obj, "unknown1")).To(BeFalse()) + + // test isFalse + g.Expect(IsFalse(obj, "nil1")).To(BeFalse()) + g.Expect(IsFalse(obj, "true1")).To(BeFalse()) + g.Expect(IsFalse(obj, "false1")).To(BeTrue()) + g.Expect(IsFalse(obj, "unknown1")).To(BeFalse()) + + // test isUnknown + g.Expect(IsUnknown(obj, "nil1")).To(BeTrue()) + g.Expect(IsUnknown(obj, "true1")).To(BeFalse()) + g.Expect(IsUnknown(obj, "false1")).To(BeFalse()) + g.Expect(IsUnknown(obj, "unknown1")).To(BeTrue()) + + // test GetReason + g.Expect(GetReason(obj, "nil1")).To(Equal("")) + g.Expect(GetReason(obj, "false1")).To(Equal("reason false1")) + + // test GetMessage + g.Expect(GetMessage(obj, "nil1")).To(Equal("")) + g.Expect(GetMessage(obj, "false1")).To(Equal("message false1")) + + // test GetLastTransitionTime + g.Expect(GetLastTransitionTime(obj, "nil1")).To(BeNil()) + g.Expect(GetLastTransitionTime(obj, "false1")).ToNot(BeNil()) + + // test GetObservedGeneration + g.Expect(GetObservedGeneration(obj, "nil1")).To(BeZero()) + g.Expect(GetObservedGeneration(obj, "false2")).ToNot(BeZero()) +} + +func TestMirror(t *testing.T) { + foo := FalseCondition("foo", "reason foo", "message foo") + ready := TrueCondition(meta.ReadyCondition, "reason ready", "message ready") + readyBar := ready.DeepCopy() + readyBar.Type = "bar" + + tests := []struct { + name string + from Getter + t string + want *metav1.Condition + }{ + { + name: "Returns nil when the ready condition does not exists", + from: getterWithConditions(foo), + want: nil, + }, + { + name: "Returns ready condition from source", + from: getterWithConditions(ready, foo), + t: "bar", + want: readyBar, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := mirror(tt.from, tt.t) + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func TestSummary(t *testing.T) { + foo := TrueCondition("foo", "reason trueFoo", "message trueFoo") + bar := FalseCondition("bar", "reason falseBar", "message falseBar") + baz := FalseCondition("baz", "reason falseBaz", "message falseBaz") + existingReady := FalseCondition(meta.ReadyCondition, "reason falseReady", "message falseReady") //NB. existing ready has higher priority than other conditions + + tests := []struct { + name string + from Getter + options []MergeOption + want *metav1.Condition + }{ + { + name: "Returns nil when there are no conditions to summarize", + from: getterWithConditions(), + want: nil, + }, + { + name: "Returns ready condition with the summary of existing conditions (with default options)", + from: getterWithConditions(foo, bar), + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "message falseBar"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounter options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounter()}, + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "1 of 2 completed"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounterIf options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounterIf(false)}, + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "message falseBar"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounterIf options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounterIf(true)}, + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "1 of 2 completed"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounterIf and WithStepCounterIfOnly options)", + from: getterWithConditions(bar), + options: []MergeOption{WithStepCounter(), WithStepCounterIfOnly("bar")}, + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "0 of 1 completed"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounterIf and WithStepCounterIfOnly options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounter(), WithStepCounterIfOnly("foo")}, + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "message falseBar"), + }, + { + name: "Returns ready condition with the summary of selected conditions (using WithConditions options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithConditions("foo")}, // bar should be ignored + want: TrueCondition(meta.ReadyCondition, "reason trueFoo", "message trueFoo"), + }, + { + name: "Returns ready condition with the summary of selected conditions (using WithConditions and WithStepCounter options)", + from: getterWithConditions(foo, bar, baz), + options: []MergeOption{WithConditions("foo", "bar"), WithStepCounter()}, // baz should be ignored, total steps should be 2 + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "1 of 2 completed"), + }, + { + name: "Returns ready condition with the summary of selected conditions (using WithConditions and WithStepCounterIfOnly options)", + from: getterWithConditions(bar), + options: []MergeOption{WithConditions("bar", "baz"), WithStepCounter(), WithStepCounterIfOnly("bar")}, // there is only bar, the step counter should be set and counts only a subset of conditions + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "0 of 1 completed"), + }, + { + name: "Returns ready condition with the summary of selected conditions (using WithConditions and WithStepCounterIfOnly options - with inconsistent order between the two)", + from: getterWithConditions(bar), + options: []MergeOption{WithConditions("baz", "bar"), WithStepCounter(), WithStepCounterIfOnly("bar", "baz")}, // conditions in WithStepCounterIfOnly could be in different order than in WithConditions + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "0 of 2 completed"), + }, + { + name: "Returns ready condition with the summary of selected conditions (using WithConditions and WithStepCounterIfOnly options)", + from: getterWithConditions(bar, baz), + options: []MergeOption{WithConditions("bar", "baz"), WithStepCounter(), WithStepCounterIfOnly("bar")}, // there is also baz, so the step counter should not be set + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "message falseBar"), + }, + { + name: "Ready condition respects merge order", + from: getterWithConditions(bar, baz), + options: []MergeOption{WithConditions("baz", "bar")}, // baz should take precedence on bar + want: FalseCondition(meta.ReadyCondition, "reason falseBaz", "message falseBaz"), + }, + { + name: "Ignores existing Ready condition when computing the summary", + from: getterWithConditions(existingReady, foo, bar), + want: FalseCondition(meta.ReadyCondition, "reason falseBar", "message falseBar"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := summary(tt.from, meta.ReadyCondition, tt.options...) + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func TestAggregate(t *testing.T) { + ready1 := TrueCondition(meta.ReadyCondition, "reason true1", "message true1") + ready2 := FalseCondition(meta.ReadyCondition, "reason false1", "message false1") + bar := FalseCondition("bar", "reason falseBar1", "message falseBar1") //NB. bar has higher priority than other conditions + + tests := []struct { + name string + from []Getter + t string + opts []MergeOption + want *metav1.Condition + }{ + { + name: "Returns nil when there are no conditions to aggregate", + from: []Getter{}, + want: nil, + }, + { + name: "Returns foo condition with an aggregation of the object's top group conditions", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + t: "foo", + want: FalseCondition("foo", "reason false1", "message false1"), + }, + { + name: "Returns foo condition with the aggregation of object's subset conditions", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + opts: []MergeOption{ + WithConditions("bar"), + }, + t: "foo", + want: FalseCondition("foo", "reason falseBar1", "message falseBar1"), + }, + { + name: "Returns foo condition with the aggregation of object's subset priority conditions", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + opts: []MergeOption{ + WithConditions("bar", meta.ReadyCondition), + }, + t: "foo", + want: FalseCondition("foo", "reason falseBar1", "message falseBar1"), + }, + { + name: "Returns foo condition with the aggregation of object's subset priority conditions (inverse)", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + opts: []MergeOption{ + WithConditions(meta.ReadyCondition, "bar"), + }, + t: "foo", + want: FalseCondition("foo", "reason false1", "message false1"), + }, + { + name: "Returns foo condition with source ref", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + opts: []MergeOption{ + WithSourceRefIf(meta.ReadyCondition), + }, + t: "foo", + want: FalseCondition("foo", "reason false1 @ /", "message false1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := aggregate(tt.from, tt.t, tt.opts...) + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func getterWithConditions(conditions ...*metav1.Condition) Getter { + obj := &fake{} + obj.SetConditions(conditionList(conditions...)) + return obj +} + +func conditionList(conditions ...*metav1.Condition) []metav1.Condition { + cs := []metav1.Condition{} + for _, x := range conditions { + if x != nil { + cs = append(cs, *x) + } + } + return cs +} diff --git a/runtime/conditions/matcher.go b/runtime/conditions/matcher.go new file mode 100644 index 00000000..850f6264 --- /dev/null +++ b/runtime/conditions/matcher.go @@ -0,0 +1,105 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/matcher.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "fmt" + + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// MatchConditions returns a custom matcher to check equality of a metav1.Condition slice, the condition messages are +// checked for a subset string match. +func MatchConditions(expected []metav1.Condition) types.GomegaMatcher { + return &matchConditions{ + expected: expected, + } +} + +type matchConditions struct { + expected []metav1.Condition +} + +func (m matchConditions) Match(actual interface{}) (success bool, err error) { + elems := []interface{}{} + for _, condition := range m.expected { + elems = append(elems, MatchCondition(condition)) + } + return ConsistOf(elems).Match(actual) +} + +func (m matchConditions) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto match\n\t%#v\n", actual, m.expected) +} + +func (m matchConditions) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto not match\n\t%#v\n", actual, m.expected) +} + +// MatchCondition returns a custom matcher to check equality of metav1.Condition. +func MatchCondition(expected metav1.Condition) types.GomegaMatcher { + return &matchCondition{ + expected: expected, + } +} + +type matchCondition struct { + expected metav1.Condition +} + +func (m matchCondition) Match(actual interface{}) (success bool, err error) { + actualCondition, ok := actual.(metav1.Condition) + if !ok { + return false, fmt.Errorf("actual should be of type Condition") + } + + ok, err = Equal(m.expected.Type).Match(actualCondition.Type) + if !ok { + return ok, err + } + ok, err = Equal(m.expected.Status).Match(actualCondition.Status) + if !ok { + return ok, err + } + ok, err = Equal(m.expected.Reason).Match(actualCondition.Reason) + if !ok { + return ok, err + } + ok, err = ContainSubstring(m.expected.Message).Match(actualCondition.Message) + if !ok { + return ok, err + } + + return ok, err +} + +func (m matchCondition) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto match\n\t%#v\n", actual, m.expected) +} + +func (m matchCondition) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto not match\n\t%#v\n", actual, m.expected) +} diff --git a/runtime/conditions/matcher_test.go b/runtime/conditions/matcher_test.go new file mode 100644 index 00000000..dbbdd294 --- /dev/null +++ b/runtime/conditions/matcher_test.go @@ -0,0 +1,298 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/matcher_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMatchConditions(t *testing.T) { + testCases := []struct { + name string + actual interface{} + expected []metav1.Condition + expectMatch bool + }{ + { + name: "with an empty conditions", + actual: []metav1.Condition{}, + expected: []metav1.Condition{}, + expectMatch: true, + }, + { + name: "with matching conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expectMatch: true, + }, + { + name: "with non-matching conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "different", + Message: "different", + }, + }, + expectMatch: false, + }, + { + name: "with a different number of conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expectMatch: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + if tc.expectMatch { + g.Expect(tc.actual).To(MatchConditions(tc.expected)) + } else { + g.Expect(tc.actual).ToNot(MatchConditions(tc.expected)) + } + }) + } +} + +func TestMatchCondition(t *testing.T) { + testCases := []struct { + name string + actual interface{} + expected metav1.Condition + expectMatch bool + }{ + { + name: "with an empty condition", + actual: metav1.Condition{}, + expected: metav1.Condition{}, + expectMatch: true, + }, + { + name: "with a matching condition", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: true, + }, + { + name: "with a different time", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Time{}, + Reason: "reason", + Message: "message", + }, + expectMatch: true, + }, + { + name: "with a different type", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different status", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different reason", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "different", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different message", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "different", + }, + expectMatch: false, + }, + { + name: "with a subset message", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "mes", + }, + expectMatch: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + if tc.expectMatch { + g.Expect(tc.actual).To(MatchCondition(tc.expected)) + } else { + g.Expect(tc.actual).ToNot(MatchCondition(tc.expected)) + } + }) + } +} diff --git a/runtime/conditions/matchers.go b/runtime/conditions/matchers.go new file mode 100644 index 00000000..9004e978 --- /dev/null +++ b/runtime/conditions/matchers.go @@ -0,0 +1,60 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/matchers.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "errors" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HaveSameStateOf returns a custom matcher to check equality of a metav1.Condition, the condition message is checked +// for a subset string match. +func HaveSameStateOf(expected *metav1.Condition) types.GomegaMatcher { + return &ConditionMatcher{ + Expected: expected, + } +} + +type ConditionMatcher struct { + Expected *metav1.Condition +} + +func (matcher *ConditionMatcher) Match(actual interface{}) (success bool, err error) { + actualCondition, ok := actual.(*metav1.Condition) + if !ok { + return false, errors.New("value should be a condition") + } + return hasSameState(actualCondition, matcher.Expected), nil +} + +func (matcher *ConditionMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, "to have the same state of", matcher.Expected) +} + +func (matcher *ConditionMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, "not to have the same state of", matcher.Expected) +} diff --git a/runtime/conditions/merge.go b/runtime/conditions/merge.go new file mode 100644 index 00000000..baed5007 --- /dev/null +++ b/runtime/conditions/merge.go @@ -0,0 +1,210 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/merge.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "sort" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// localizedCondition defines a condition with the information of the object the conditions was originated from. +type localizedCondition struct { + *metav1.Condition + Getter +} + +// merge a list of condition into a single one. +// This operation is designed to ensure visibility of the most relevant conditions for defining the operational state of +// a component. E.g. If there is one error in the condition list, this one takes priority over the other conditions and +// it is should be reflected in the target condition. +// +// More specifically: +// 1. Conditions are grouped by status and polarity. +// 2. The resulting condition groups are sorted according to the following priority: +// - P0 - Status=True, NegativePolarity=True +// - P1 - Status=False, NegativePolarity=False +// - P2 - Condition=True, NegativePolarity=False +// - P3 - Status=False, NegativePolarity=True +// - P4 - Status=Unknown +// 3. The group with highest priority is used to determine status, and other info of the target condition. +// 4. If the polarity of the highest priority and target priority differ, it is inverted. +// +// Please note that the last operation includes also the task of computing the Reason and the Message for the target +// condition; in order to complete such task some trade-off should be made, because there is no a golden rule for +// summarizing many Reason/Message into single Reason/Message. mergeOptions allows the user to adapt this process to the +// specific needs by exposing a set of merge strategies. +func merge(conditions []localizedCondition, targetCondition string, options *mergeOptions) *metav1.Condition { + g := getConditionGroups(conditions, options) + if len(g) == 0 { + return nil + } + + topGroup := g.TopGroup() + targetReason := getReason(g, options) + targetMessage := getMessage(g, options) + targetNegativePolarity := stringInSlice(options.negativePolarityConditionTypes, targetCondition) + + switch topGroup.status { + case metav1.ConditionTrue: + // Inverse the negative polarity if the target condition has positive polarity. + if topGroup.negativePolarity != targetNegativePolarity { + return FalseCondition(targetCondition, targetReason, targetMessage) + } + return TrueCondition(targetCondition, targetReason, targetMessage) + case metav1.ConditionFalse: + // Inverse the negative polarity if the target condition has positive polarity. + if topGroup.negativePolarity != targetNegativePolarity { + return TrueCondition(targetCondition, targetReason, targetMessage) + } + return FalseCondition(targetCondition, targetReason, targetMessage) + default: + return UnknownCondition(targetCondition, targetReason, targetMessage) + } +} + +// getConditionGroups groups a list of conditions according to status values and polarity. +// Additionally, the resulting groups are sorted by mergePriority. +func getConditionGroups(conditions []localizedCondition, options *mergeOptions) conditionGroups { + groups := conditionGroups{} + + for _, condition := range conditions { + if condition.Condition == nil { + continue + } + + added := false + for i := range groups { + if groups[i].status == condition.Status && + groups[i].negativePolarity == stringInSlice(options.negativePolarityConditionTypes, condition.Type) { + groups[i].conditions = append(groups[i].conditions, condition) + added = true + break + } + } + if !added { + groups = append(groups, conditionGroup{ + conditions: []localizedCondition{condition}, + status: condition.Status, + negativePolarity: stringInSlice(options.negativePolarityConditionTypes, condition.Type), + }) + } + } + + // sort groups by priority + sort.Sort(groups) + + // sorts conditions in the TopGroup so we ensure predictable result for merge strategies. + // condition are sorted using the same lexicographic order used by Set; in case two conditions + // have the same type, condition are sorted using according to the alphabetical order of the source object name. + if len(groups) > 0 { + sort.Slice(groups[0].conditions, func(i, j int) bool { + a := groups[0].conditions[i] + b := groups[0].conditions[j] + if a.Type != b.Type { + return lexicographicLess(a.Condition, b.Condition) + } + return a.GetName() < b.GetName() + }) + } + + return groups +} + +// conditionGroups provides supports for grouping a list of conditions to be merged into a single condition. +// ConditionGroups can be sorted by mergePriority. +type conditionGroups []conditionGroup + +func (g conditionGroups) Len() int { + return len(g) +} + +func (g conditionGroups) Less(i, j int) bool { + return g[i].mergePriority() < g[j].mergePriority() +} + +func (g conditionGroups) Swap(i, j int) { + g[i], g[j] = g[j], g[i] +} + +// TopGroup returns the the condition group with the highest mergePriority. +func (g conditionGroups) TopGroup() *conditionGroup { + if len(g) == 0 { + return nil + } + return &g[0] +} + +// TruePositivePolarityGroup returns the the condition group with status True/Positive, if any. +func (g conditionGroups) TruePositivePolarityGroup() *conditionGroup { + if g.Len() == 0 { + return nil + } + for _, group := range g { + if !group.negativePolarity && group.status == metav1.ConditionTrue { + return &group + } + } + return nil +} + +// conditionGroup defines a group of conditions with the same metav1.ConditionStatus and polarity, and thus with the +// same priority when merging into a condition. +type conditionGroup struct { + status metav1.ConditionStatus + negativePolarity bool + conditions []localizedCondition +} + +// mergePriority provides a priority value for the status and polarity tuple that identifies this condition group. The +// mergePriority value allows an easier sorting of conditions groups. +func (g conditionGroup) mergePriority() (p int) { + switch g.status { + case metav1.ConditionTrue: + p = 0 + if !g.negativePolarity { + p = 2 + } + return + case metav1.ConditionFalse: + p = 1 + if g.negativePolarity { + p = 3 + } + return + case metav1.ConditionUnknown: + return 4 + default: + return 99 + } +} + +func stringInSlice(s []string, val string) bool { + for _, s := range s { + if s == val { + return true + } + } + return false +} diff --git a/runtime/conditions/merge_strategies.go b/runtime/conditions/merge_strategies.go new file mode 100644 index 00000000..04dd01fe --- /dev/null +++ b/runtime/conditions/merge_strategies.go @@ -0,0 +1,233 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/merge_strategies.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "fmt" + "strings" +) + +// mergeOptions allows to set strategies for merging a set of conditions into a single condition, and more specifically +// for computing the target Reason and the target Message. +type mergeOptions struct { + conditionTypes []string + negativePolarityConditionTypes []string + + addSourceRef bool + addSourceRefIfConditionTypes []string + addCounter bool + addCounterOnlyIfConditionTypes []string + addStepCounter bool + addStepCounterIfOnlyConditionTypes []string + + stepCounter int +} + +// MergeOption defines an option for computing a summary of conditions. +type MergeOption func(*mergeOptions) + +// WithConditions instructs merge about the condition types to consider when doing a merge operation; if this option is +// not specified, all the conditions (except Ready, Stalled, and Reconciling) will be considered. This is required so we +// can provide some guarantees about the semantic of the target condition without worrying about side effects if someone +// or something adds custom conditions to the objects. +// +// NOTE: The order of conditions types defines the priority for determining the Reason and Message for the target +// condition. +// IMPORTANT: This options works only while generating a Summary or Aggregated condition. +func WithConditions(t ...string) MergeOption { + return func(c *mergeOptions) { + c.conditionTypes = t + } +} + +// WithNegativePolarityConditions instructs merge about the condition types that adhere to a "normal-false" or +// "abnormal-true" pattern, i.e. that conditions are present with a value of True whenever something unusual happens. +// +// NOTE: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties +// IMPORTANT: This option works only while generating the Summary or Aggregated condition. +func WithNegativePolarityConditions(t ...string) MergeOption { + return func(c *mergeOptions) { + c.negativePolarityConditionTypes = t + } +} + +// WithCounter instructs merge to add a "x of y Type" string to the message, where x is the number of conditions in the +// top group, y is the number of objects in scope, and Type is the top group condition type. +func WithCounter() MergeOption { + return func(c *mergeOptions) { + c.addCounter = true + } +} + +// WithCounterIfOnly ensures a counter is show only if a subset of condition exists. +// This may apply when you want to use a step counter while reconciling the resource, but then want to move away from +// this notation as soon as the resource has been reconciled, and e.g. a health check condition is generated. +// +// IMPORTANT: This options requires WithStepCounter or WithStepCounterIf to be set. +// IMPORTANT: This option works only while generating the Aggregated condition. +func WithCounterIfOnly(t ...string) MergeOption { + return func(c *mergeOptions) { + c.addCounterOnlyIfConditionTypes = t + } +} + +// WithStepCounter instructs merge to add a "x of y completed" string to the message, where x is the number of +// conditions with Status=true and y is the number of conditions in scope. +// +// IMPORTANT: This option works only while generating the Summary or Aggregated condition. +func WithStepCounter() MergeOption { + return func(c *mergeOptions) { + c.addStepCounter = true + } +} + +// WithStepCounterIf adds a step counter if the value is true. +// This can be used e.g. to add a step counter only if the object is not being deleted. +// +// IMPORTANT: This option works only while generating the Summary or Aggregated condition. +func WithStepCounterIf(value bool) MergeOption { + return func(c *mergeOptions) { + c.addStepCounter = value + } +} + +// WithStepCounterIfOnly ensures a step counter is show only if a subset of condition exists. +// This may apply when you want to use a step counter while reconciling the resource, but then want to move away from +// this notation as soon as the resource has been reconciled, and e.g. a health check condition is generated. +// +// IMPORTANT: This options requires WithStepCounter or WithStepCounterIf to be set. +// IMPORTANT: This option works only while generating the Summary or Aggregated condition. +func WithStepCounterIfOnly(t ...string) MergeOption { + return func(c *mergeOptions) { + c.addStepCounterIfOnlyConditionTypes = t + } +} + +// WithSourceRef instructs merge to add info about the originating object to the target Reason and +// in summaries. +func WithSourceRef() MergeOption { + return func(c *mergeOptions) { + c.addSourceRef = true + } +} + +// WithSourceRefIf ensures a source ref is show only if one of the types in the set exists. +func WithSourceRefIf(t ...string) MergeOption { + return func(c *mergeOptions) { + c.addSourceRefIfConditionTypes = t + } +} + +// getReason returns the reason to be applied to the condition resulting by merging a set of condition groups. +// The reason is computed according to the given mergeOptions. +func getReason(groups conditionGroups, options *mergeOptions) string { + return getFirstReason(groups, options.conditionTypes, options.addSourceRef) +} + +// getFirstReason returns the first reason from the ordered list of conditions in the top group. +// If required, the reason gets localized with the source object reference. +func getFirstReason(g conditionGroups, order []string, addSourceRef bool) string { + if condition := getFirstCondition(g, order); condition != nil { + reason := condition.Reason + if addSourceRef { + return localizeReason(reason, condition.Getter) + } + return reason + } + return "" +} + +// localizeReason adds info about the originating object to the target Reason. +func localizeReason(reason string, from Getter) string { + if strings.Contains(reason, "@") { + return reason + } + return fmt.Sprintf("%s @ %s/%s", reason, from.GetObjectKind().GroupVersionKind().Kind, from.GetName()) +} + +// getMessage returns the message to be applied to the condition resulting by merging a set of condition groups. +// The message is computed according to the given mergeOptions, but in case of errors or warning a summary of existing +// errors is automatically added. +func getMessage(groups conditionGroups, options *mergeOptions) string { + if options.addStepCounter { + return getStepCounterMessage(groups, options.stepCounter) + } + if options.addCounter { + return getCounterMessage(groups, options.stepCounter) + } + return getFirstMessage(groups, options.conditionTypes) +} + +// getCounterMessage returns a "x of y ", where x is the number of conditions in the top group, y is the number +// passed to this method and is the condition type of the top group. +func getCounterMessage(groups conditionGroups, to int) string { + topGroup := groups.TopGroup() + if topGroup == nil { + return fmt.Sprintf("%d of %d", 0, to) + } + ct := len(topGroup.conditions) + return fmt.Sprintf("%d of %d %s", ct, to, topGroup.conditions[0].Type) +} + +// getStepCounterMessage returns a message "x of y completed", where x is the number of conditions with Status=True and +// Polarity=Positive and y is the number passed to this method. +func getStepCounterMessage(groups conditionGroups, to int) string { + ct := 0 + if trueGroup := groups.TruePositivePolarityGroup(); trueGroup != nil { + ct = len(trueGroup.conditions) + } + return fmt.Sprintf("%d of %d completed", ct, to) +} + +// getFirstMessage returns the message from the ordered list of conditions in the top group. +func getFirstMessage(groups conditionGroups, order []string) string { + if condition := getFirstCondition(groups, order); condition != nil { + return condition.Message + } + return "" +} + +// getFirstCondition returns a first condition from the ordered list of conditions in the top group. +func getFirstCondition(g conditionGroups, priority []string) *localizedCondition { + topGroup := g.TopGroup() + if topGroup == nil { + return nil + } + + switch len(topGroup.conditions) { + case 0: + return nil + case 1: + return &topGroup.conditions[0] + default: + for _, p := range priority { + for _, c := range topGroup.conditions { + if c.Type == p { + return &c + } + } + } + return &topGroup.conditions[0] + } +} diff --git a/runtime/conditions/merge_strategies_test.go b/runtime/conditions/merge_strategies_test.go new file mode 100644 index 00000000..c09e2fb6 --- /dev/null +++ b/runtime/conditions/merge_strategies_test.go @@ -0,0 +1,114 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/merge_strategies_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetStepCounterMessage(t *testing.T) { + g := NewWithT(t) + + groups := getConditionGroups(conditionsWithSource(&fake{}, + nil1, + true1, true1, + false1, false1, false1, + unknown1, + ), &mergeOptions{}) + + got := getStepCounterMessage(groups, 8) + + // step count message should report n° if true conditions over to number + g.Expect(got).To(Equal("2 of 8 completed")) +} + +func TestLocalizeReason(t *testing.T) { + g := NewWithT(t) + + getter := &fake{ + TypeMeta: metav1.TypeMeta{ + Kind: "Fake", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-fake", + }, + } + + // localize should reason location + got := localizeReason("foo", getter) + g.Expect(got).To(Equal("foo @ Fake/test-fake")) + + // localize should not alter existing location + got = localizeReason("foo @ SomeKind/some-name", getter) + g.Expect(got).To(Equal("foo @ SomeKind/some-name")) +} + +func TestGetFirstReasonAndMessage(t *testing.T) { + g := NewWithT(t) + + foo := FalseCondition("foo", "falseFoo", "message falseFoo") + bar := FalseCondition("bar", "falseBar", "message falseBar") + + setter := &fake{ + TypeMeta: metav1.TypeMeta{ + Kind: "Fake", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-fake", + }, + } + + groups := getConditionGroups(conditionsWithSource(setter, foo, bar), &mergeOptions{}) + + // getFirst should report first condition in lexicografical order if no order is specified + gotReason := getFirstReason(groups, nil, false) + g.Expect(gotReason).To(Equal("falseBar")) + gotMessage := getFirstMessage(groups, nil) + g.Expect(gotMessage).To(Equal("message falseBar")) + + // getFirst should report should respect order + gotReason = getFirstReason(groups, []string{"foo", "bar"}, false) + g.Expect(gotReason).To(Equal("falseFoo")) + gotMessage = getFirstMessage(groups, []string{"foo", "bar"}) + g.Expect(gotMessage).To(Equal("message falseFoo")) + + // getFirst should report should respect order in case of missing conditions + gotReason = getFirstReason(groups, []string{"missingBaz", "foo", "bar"}, false) + g.Expect(gotReason).To(Equal("falseFoo")) + gotMessage = getFirstMessage(groups, []string{"missingBaz", "foo", "bar"}) + g.Expect(gotMessage).To(Equal("message falseFoo")) + + // getFirst should fallback to first condition if none of the conditions in the list exists + gotReason = getFirstReason(groups, []string{"missingBaz"}, false) + g.Expect(gotReason).To(Equal("falseBar")) + gotMessage = getFirstMessage(groups, []string{"missingBaz"}) + g.Expect(gotMessage).To(Equal("message falseBar")) + + // getFirstReason should localize reason if required + gotReason = getFirstReason(groups, nil, true) + g.Expect(gotReason).To(Equal("falseBar @ Fake/test-fake")) +} diff --git a/runtime/conditions/merge_test.go b/runtime/conditions/merge_test.go new file mode 100644 index 00000000..2a9867b1 --- /dev/null +++ b/runtime/conditions/merge_test.go @@ -0,0 +1,192 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/merge_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + + "github.com/fluxcd/pkg/apis/meta" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewConditionsGroup(t *testing.T) { + g := NewWithT(t) + + negativeFalseReconciling := FalseCondition(meta.ReconcilingCondition, "reason reconciling1", "message reconciling1") + negativeTrueStalled := TrueCondition(meta.StalledCondition, "reason stalled1", "message stalled1") + negativeUnknownReconciling := UnknownCondition(meta.ReconcilingCondition, "reason reconciling2", "message reconciling2") + + conditions := []*metav1.Condition{nil1, true1, true1, false1, unknown1, negativeFalseReconciling, negativeTrueStalled, negativeUnknownReconciling} + + got := getConditionGroups(conditionsWithSource(&fake{}, conditions...), &mergeOptions{ + negativePolarityConditionTypes: []string{meta.ReconcilingCondition, meta.StalledCondition}, + }) + + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(HaveLen(6)) + + // The TopGroup should be True/Negative and it should have one condition + g.Expect(got.TopGroup().status).To(Equal(metav1.ConditionTrue)) + g.Expect(got.TopGroup().negativePolarity).To(BeTrue()) + g.Expect(got.TopGroup().conditions).To(HaveLen(1)) + + // The TruePositivePolarityGroup should be True/Positive and it should have one condition + g.Expect(got.TruePositivePolarityGroup().status).To(Equal(metav1.ConditionTrue)) + g.Expect(got.TruePositivePolarityGroup().negativePolarity).To(BeFalse()) + g.Expect(got.TruePositivePolarityGroup().conditions).To(HaveLen(2)) + + // got[0] should be True/Negative and it should have one condition + g.Expect(got[0].status).To(Equal(metav1.ConditionTrue)) + g.Expect(got[0].negativePolarity).To(BeTrue()) + g.Expect(got[0].conditions).To(HaveLen(1)) + + // got[1] should be False/Positive and it should have one conditions + g.Expect(got[1].status).To(Equal(metav1.ConditionFalse)) + g.Expect(got[1].negativePolarity).To(BeFalse()) + g.Expect(got[1].conditions).To(HaveLen(1)) + + // got[2] should be True/Positive and it should have two conditions + g.Expect(got[2].status).To(Equal(metav1.ConditionTrue)) + g.Expect(got[1].negativePolarity).To(BeFalse()) + g.Expect(got[2].conditions).To(HaveLen(2)) + + // got[3] should be False/Negative and it should have one condition + g.Expect(got[3].status).To(Equal(metav1.ConditionFalse)) + g.Expect(got[3].negativePolarity).To(BeTrue()) + g.Expect(got[3].conditions).To(HaveLen(1)) + + // got[4] should be Unknown/Positive and it should have one condition + g.Expect(got[4].status).To(Equal(metav1.ConditionUnknown)) + g.Expect(got[4].negativePolarity).To(BeFalse()) + g.Expect(got[4].conditions).To(HaveLen(1)) + + // got[5] should be Unknown/Negative and it should have one condition + g.Expect(got[5].status).To(Equal(metav1.ConditionUnknown)) + g.Expect(got[5].negativePolarity).To(BeTrue()) + g.Expect(got[3].conditions).To(HaveLen(1)) + + // nil conditions are ignored +} + +func TestMergeRespectPriority(t *testing.T) { + tests := []struct { + name string + negativeConditions []string + conditions []*metav1.Condition + want *metav1.Condition + }{ + { + name: "aggregate nil list return nil", + conditions: nil, + want: nil, + }, + { + name: "aggregate empty list return nil", + conditions: []*metav1.Condition{}, + want: nil, + }, + { + name: "When there is True/Negative it returns an inverted False/Positive", + negativeConditions: []string{true1.Type}, + conditions: []*metav1.Condition{false1, false1, false1, unknown1, true1}, + want: FalseCondition("foo", "reason true1", "message true1"), + }, + { + name: "When there is False/Positive and no True/Negative, it returns False/Positive", + conditions: []*metav1.Condition{false1, false1, unknown1, true1}, + want: FalseCondition("foo", "reason false1", "message false1"), + }, + { + name: "When there is True/Positive and no True/Negative or False/Positive, it returns True/Positive", + negativeConditions: []string{false1.Type}, + conditions: []*metav1.Condition{false1, unknown1, true1}, + want: TrueCondition("foo", "reason true1", "message true1"), + }, + { + name: "When there is True/Positive and no False/Positive, it returns True/Positive", + negativeConditions: []string{false1.Type}, + conditions: []*metav1.Condition{unknown1, true1, false1}, + want: TrueCondition("foo", "reason true1", "message true1"), + }, + { + name: "When there is False/Negative and no True/* or False/Positive, it returns False/Negative", + negativeConditions: []string{false1.Type}, + conditions: []*metav1.Condition{unknown1, false1}, + want: TrueCondition("foo", "reason false1", "message false1"), + }, + { + name: "When there is Unknown/* but no False/*, it returns Unknown/*", + conditions: []*metav1.Condition{unknown1}, + want: UnknownCondition("foo", "reason unknown1", "message unknown1"), + }, + { + name: "When the target condition is inverted, it returns an inverted condition", + negativeConditions: []string{"foo"}, + conditions: []*metav1.Condition{true1}, + want: FalseCondition("foo", "reason true1", "message true1"), + }, + { + name: "When the top and target conditions are inverted, it returns an equal condition", + negativeConditions: []string{"foo", true1.Type}, + conditions: []*metav1.Condition{true1}, + want: TrueCondition("foo", "reason true1", "message true1"), + }, + { + name: "nil conditions are ignored", + conditions: []*metav1.Condition{nil1, nil1, nil1}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := merge(conditionsWithSource(&fake{}, tt.conditions...), "foo", &mergeOptions{ + negativePolarityConditionTypes: tt.negativeConditions, + }) + + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func conditionsWithSource(obj Setter, conditions ...*metav1.Condition) []localizedCondition { + obj.SetConditions(conditionList(conditions...)) + + ret := []localizedCondition{} + for i := range conditions { + ret = append(ret, localizedCondition{ + Condition: conditions[i], + Getter: obj, + }) + } + + return ret +} diff --git a/runtime/conditions/patch.go b/runtime/conditions/patch.go new file mode 100644 index 00000000..94fd3e16 --- /dev/null +++ b/runtime/conditions/patch.go @@ -0,0 +1,208 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/patch.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "reflect" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Patch defines a list of operations to change a list of conditions into another. +type Patch []PatchOperation + +// PatchOperation define an operation that changes a single condition. +type PatchOperation struct { + Before *metav1.Condition + After *metav1.Condition + Op PatchOperationType +} + +// PatchOperationType defines patch operation types. +type PatchOperationType string + +const ( + // AddConditionPatch defines an add condition patch operation. + AddConditionPatch PatchOperationType = "Add" + + // ChangeConditionPatch defines an change condition patch operation. + ChangeConditionPatch PatchOperationType = "Change" + + // RemoveConditionPatch defines a remove condition patch operation. + RemoveConditionPatch PatchOperationType = "Remove" +) + +// NewPatch returns the list of Patch required to align source conditions to after conditions. +func NewPatch(before Getter, after Getter) Patch { + var patch Patch + + // Identify AddCondition and ModifyCondition changes. + targetConditions := after.GetConditions() + for i := range targetConditions { + targetCondition := targetConditions[i] + currentCondition := Get(before, targetCondition.Type) + if currentCondition == nil { + patch = append(patch, PatchOperation{Op: AddConditionPatch, After: &targetCondition}) + continue + } + + if !reflect.DeepEqual(&targetCondition, currentCondition) { + patch = append(patch, PatchOperation{Op: ChangeConditionPatch, After: &targetCondition, Before: currentCondition}) + } + } + + // Identify RemoveCondition changes. + baseConditions := before.GetConditions() + for i := range baseConditions { + baseCondition := baseConditions[i] + targetCondition := Get(after, baseCondition.Type) + if targetCondition == nil { + patch = append(patch, PatchOperation{Op: RemoveConditionPatch, Before: &baseCondition}) + } + } + return patch +} + +// applyOptions allows to set strategies for patch apply. +type applyOptions struct { + ownedConditions []string + forceOverwrite bool +} + +func (o *applyOptions) isOwnedCondition(t string) bool { + for _, i := range o.ownedConditions { + if i == t { + return true + } + } + return false +} + +// ApplyOption defines an option for applying a condition patch. +type ApplyOption func(*applyOptions) + +// WithOwnedConditions allows to define condition types owned by the controller. +// In case of conflicts for the owned conditions, the patch helper will always use the value provided by the controller. +func WithOwnedConditions(t ...string) ApplyOption { + return func(c *applyOptions) { + c.ownedConditions = t + } +} + +// WithForceOverwrite instructs the patch helper to always use the value provided by the controller in case of conflicts +// for the owned conditions. +func WithForceOverwrite(v bool) ApplyOption { + return func(c *applyOptions) { + c.forceOverwrite = v + } +} + +// Apply executes a three-way merge of a list of Patch. +// When merge conflicts are detected (latest deviated from before in an incompatible way), an error is returned. +func (p Patch) Apply(latest Setter, options ...ApplyOption) error { + if len(p) == 0 { + return nil + } + + applyOpt := &applyOptions{} + for _, o := range options { + o(applyOpt) + } + + for _, conditionPatch := range p { + switch conditionPatch.Op { + case AddConditionPatch: + // If the conditions is owned, always keep the after value. + if applyOpt.forceOverwrite || applyOpt.isOwnedCondition(conditionPatch.After.Type) { + Set(latest, conditionPatch.After) + continue + } + + // If the condition is already on latest, check if latest and after agree on the change; if not, this is a conflict. + if latestCondition := Get(latest, conditionPatch.After.Type); latestCondition != nil { + // If latest and after agree on the change, then it is a conflict. + if !hasSameState(latestCondition, conditionPatch.After) { + return errors.Errorf("error patching conditions: The condition %q was modified by a different process and this caused a merge/AddCondition conflict: %v", conditionPatch.After.Type, cmp.Diff(latestCondition, conditionPatch.After)) + } + // otherwise, the latest is already as intended. + // NOTE: We are preserving LastTransitionTime from the latest in order to avoid altering the existing value. + continue + } + // If the condition does not exists on the latest, add the new after condition. + Set(latest, conditionPatch.After) + + case ChangeConditionPatch: + // If the conditions is owned, always keep the after value. + if applyOpt.forceOverwrite || applyOpt.isOwnedCondition(conditionPatch.After.Type) { + Set(latest, conditionPatch.After) + continue + } + + latestCondition := Get(latest, conditionPatch.After.Type) + + // If the condition does not exist anymore on the latest, this is a conflict. + if latestCondition == nil { + return errors.Errorf("error patching conditions: The condition %q was deleted by a different process and this caused a merge/ChangeCondition conflict", conditionPatch.After.Type) + } + + // If the condition on the latest is different from the base condition, check if + // the after state corresponds to the desired value. If not this is a conflict (unless we should ignore conflicts for this condition type). + if !reflect.DeepEqual(latestCondition, conditionPatch.Before) { + if !hasSameState(latestCondition, conditionPatch.After) { + return errors.Errorf("error patching conditions: The condition %q was modified by a different process and this caused a merge/ChangeCondition conflict: %v", conditionPatch.After.Type, cmp.Diff(latestCondition, conditionPatch.After)) + } + // Otherwise the latest is already as intended. + // NOTE: We are preserving LastTransitionTime from the latest in order to avoid altering the existing value. + continue + } + // Otherwise apply the new after condition. + Set(latest, conditionPatch.After) + + case RemoveConditionPatch: + // If the conditions is owned, always keep the after value (condition should be deleted). + if applyOpt.forceOverwrite || applyOpt.isOwnedCondition(conditionPatch.Before.Type) { + Delete(latest, conditionPatch.Before.Type) + continue + } + + // If the condition is still on the latest, check if it is changed in the meantime; + // if so then this is a conflict. + if latestCondition := Get(latest, conditionPatch.Before.Type); latestCondition != nil { + if !hasSameState(latestCondition, conditionPatch.Before) { + return errors.Errorf("error patching conditions: The condition %q was modified by a different process and this caused a merge/RemoveCondition conflict: %v", conditionPatch.Before.Type, cmp.Diff(latestCondition, conditionPatch.Before)) + } + } + // Otherwise the latest and after agreed on the delete operation, so there's nothing to change. + Delete(latest, conditionPatch.Before.Type) + } + } + return nil +} + +// IsZero returns true if the patch has no changes. +func (p Patch) IsZero() bool { + return len(p) == 0 +} diff --git a/runtime/conditions/patch_test.go b/runtime/conditions/patch_test.go new file mode 100644 index 00000000..62df05fb --- /dev/null +++ b/runtime/conditions/patch_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/patch_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewPatch(t *testing.T) { + fooTrue := TrueCondition("foo", "reason true", "message true") + fooFalse := FalseCondition("foo", "reason false", "message false") + + tests := []struct { + name string + before Getter + after Getter + want Patch + }{ + { + name: "No changes return empty patch", + before: getterWithConditions(), + after: getterWithConditions(), + want: nil, + }, + { + name: "No changes return empty patch", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooTrue), + want: nil, + }, + { + name: "Detects AddConditionPatch", + before: getterWithConditions(), + after: getterWithConditions(fooTrue), + want: Patch{ + { + Before: nil, + After: fooTrue, + Op: AddConditionPatch, + }, + }, + }, + { + name: "Detects ChangeConditionPatch", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + want: Patch{ + { + Before: fooTrue, + After: fooFalse, + Op: ChangeConditionPatch, + }, + }, + }, + { + name: "Detects RemoveConditionPatch", + before: getterWithConditions(fooTrue), + after: getterWithConditions(), + want: Patch{ + { + Before: fooTrue, + After: nil, + Op: RemoveConditionPatch, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := NewPatch(tt.before, tt.after) + + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func TestApply(t *testing.T) { + fooTrue := TrueCondition("foo", "reason true", "message true") + fooFalse := FalseCondition("foo", "reason false", "message false") + fooUnknown := UnknownCondition("foo", "reason unknown", "message unknown") + + tests := []struct { + name string + before Getter + after Getter + latest Setter + options []ApplyOption + want []metav1.Condition + wantErr bool + }{ + { + name: "No patch return same list", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(fooTrue), + want: conditionList(fooTrue), + wantErr: false, + }, + { + name: "Add: When a condition does not exists, it should add", + before: getterWithConditions(), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(), + want: conditionList(fooTrue), + wantErr: false, + }, + { + name: "Add: When a condition already exists but without conflicts, it should add", + before: getterWithConditions(), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(fooTrue), + want: conditionList(fooTrue), + wantErr: false, + }, + { + name: "Add: When a condition already exists but with conflicts, it should error", + before: getterWithConditions(), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(fooFalse), + want: nil, + wantErr: true, + }, + { + name: "Add: When a condition already exists but with conflicts, it should not error if the condition is owned", + before: getterWithConditions(), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(fooFalse), + options: []ApplyOption{WithOwnedConditions("foo")}, + want: conditionList(fooTrue), // after condition should be kept in case of error + wantErr: false, + }, + { + name: "Remove: When a condition was already deleted, it should pass", + before: getterWithConditions(fooTrue), + after: getterWithConditions(), + latest: setterWithConditions(), + want: conditionList(), + wantErr: false, + }, + { + name: "Remove: When a condition already exists but without conflicts, it should delete", + before: getterWithConditions(fooTrue), + after: getterWithConditions(), + latest: setterWithConditions(fooTrue), + want: conditionList(), + wantErr: false, + }, + { + name: "Remove: When a condition already exists but with conflicts, it should error", + before: getterWithConditions(fooTrue), + after: getterWithConditions(), + latest: setterWithConditions(fooFalse), + want: nil, + wantErr: true, + }, + { + name: "Remove: When a condition already exists but with conflicts, it should not error if the condition is owned", + before: getterWithConditions(fooTrue), + after: getterWithConditions(), + latest: setterWithConditions(fooFalse), + options: []ApplyOption{WithOwnedConditions("foo")}, + want: conditionList(), // after condition should be kept in case of error + wantErr: false, + }, + { + name: "Change: When a condition exists without conflicts, it should change", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(fooTrue), + want: conditionList(fooFalse), + wantErr: false, + }, + { + name: "Change: When a condition exists with conflicts but there is agreement on the final state, it should change", + before: getterWithConditions(fooFalse), + after: getterWithConditions(fooTrue), + latest: setterWithConditions(fooTrue), + want: conditionList(fooTrue), + wantErr: false, + }, + { + name: "Change: When a condition exists with conflicts but there is no agreement on the final state, it should error", + before: getterWithConditions(fooUnknown), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(fooTrue), + want: nil, + wantErr: true, + }, + { + name: "Change: When a condition exists with conflicts but there is no agreement on the final state, it should not error if the condition is owned", + before: getterWithConditions(fooUnknown), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(fooTrue), + options: []ApplyOption{WithOwnedConditions("foo")}, + want: conditionList(fooFalse), // after condition should be kept in case of error + wantErr: false, + }, + { + name: "Change: When a condition was deleted, it should error", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(), + want: nil, + wantErr: true, + }, + { + name: "Change: When a condition was deleted, it should not error if the condition is owned", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(), + options: []ApplyOption{WithOwnedConditions("foo")}, + want: conditionList(fooFalse), // after condition should be kept in case of error + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + patch := NewPatch(tt.before, tt.after) + + err := patch.Apply(tt.latest, tt.options...) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(tt.latest.GetConditions()).To(haveSameConditionsOf(tt.want)) + }) + } +} + +func TestApplyDoesNotAlterLastTransitionTime(t *testing.T) { + g := NewWithT(t) + + before := &fake{} + after := &fake{ + Status: fakeStatus{ + Conditions: []metav1.Condition{ + { + Type: "foo", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.NewTime(time.Now().UTC().Truncate(time.Second)), + }, + }, + }, + } + latest := &fake{} + + // latest has no conditions, so we are actually adding the + // condition but in this case we should not set the LastTransitionTime + // but we should preserve the LastTransition set in after + + diff := NewPatch(before, after) + err := diff.Apply(latest) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(latest.GetConditions()).To(Equal(after.GetConditions())) +} diff --git a/runtime/conditions/setter.go b/runtime/conditions/setter.go new file mode 100644 index 00000000..3dff0ce7 --- /dev/null +++ b/runtime/conditions/setter.go @@ -0,0 +1,200 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/setter.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "fmt" + "sort" + "time" + + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Setter is an interface that defines methods a Kubernetes object should implement in order to +// use the conditions package for setting conditions. +type Setter interface { + Getter + meta.ObjectWithConditionsSetter +} + +// Set sets the given condition. +// +// NOTE: If a condition already exists, the LastTransitionTime is updated only if a change is detected in any of the +// following fields: Status, Reason, and Message. The ObservedGeneration is always updated. +func Set(to Setter, condition *metav1.Condition) { + if to == nil || condition == nil { + return + } + + // Always set the observed generation on the condition. + condition.ObservedGeneration = to.GetGeneration() + + // Check if the new conditions already exists, and change it only if there is a status + // transition (otherwise we should preserve the current last transition time)- + conditions := to.GetConditions() + exists := false + for i := range conditions { + existingCondition := conditions[i] + if existingCondition.Type == condition.Type { + exists = true + if !hasSameState(&existingCondition, condition) { + condition.LastTransitionTime = metav1.NewTime(time.Now().UTC().Truncate(time.Second)) + conditions[i] = *condition + break + } + condition.LastTransitionTime = existingCondition.LastTransitionTime + break + } + } + + // If the condition does not exist, add it, setting the transition time only if not already set + if !exists { + if condition.LastTransitionTime.IsZero() { + condition.LastTransitionTime = metav1.NewTime(time.Now().UTC().Truncate(time.Second)) + } + conditions = append(conditions, *condition) + } + + // Sorts conditions for convenience of the consumer, i.e. kubectl. + sort.Slice(conditions, func(i, j int) bool { + return lexicographicLess(&conditions[i], &conditions[j]) + }) + + to.SetConditions(conditions) +} + +// TrueCondition returns a condition with Status=True and the given type, reason and message. +func TrueCondition(t, reason, messageFormat string, messageArgs ...interface{}) *metav1.Condition { + return &metav1.Condition{ + Type: t, + Status: metav1.ConditionTrue, + Reason: reason, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// FalseCondition returns a condition with Status=False and the given type, reason and message. +func FalseCondition(t, reason, messageFormat string, messageArgs ...interface{}) *metav1.Condition { + return &metav1.Condition{ + Type: t, + Status: metav1.ConditionFalse, + Reason: reason, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// UnknownCondition returns a condition with Status=Unknown and the given type, reason and message. +func UnknownCondition(t, reason, messageFormat string, messageArgs ...interface{}) *metav1.Condition { + return &metav1.Condition{ + Type: t, + Status: metav1.ConditionUnknown, + Reason: reason, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// MarkTrue sets Status=True for the condition with the given type, reason and message. +func MarkTrue(to Setter, t, reason, messageFormat string, messageArgs ...interface{}) { + Set(to, TrueCondition(t, reason, messageFormat, messageArgs...)) +} + +// MarkUnknown sets Status=Unknown for the condition with the given type, reason and message. +func MarkUnknown(to Setter, t, reason, messageFormat string, messageArgs ...interface{}) { + Set(to, UnknownCondition(t, reason, messageFormat, messageArgs...)) +} + +// MarkFalse sets Status=False for the condition with the given type, reason and message. +func MarkFalse(to Setter, t, reason, messageFormat string, messageArgs ...interface{}) { + Set(to, FalseCondition(t, reason, messageFormat, messageArgs...)) +} + +// SetSummary creates a new summary condition with the summary of all the conditions existing on an object. +// If the object does not have other conditions, no summary condition is generated. +func SetSummary(to Setter, targetCondition string, options ...MergeOption) { + Set(to, summary(to, targetCondition, options...)) +} + +// SetMirror creates a new condition by mirroring the the Ready condition from a dependent object; +// if the Ready condition does not exists in the source object, no target conditions is generated. +func SetMirror(to Setter, targetCondition string, from Getter, options ...MirrorOptions) { + Set(to, mirror(from, targetCondition, options...)) +} + +// SetAggregate creates a new condition with the aggregation of all the conditions from a list of dependency objects, +// or a subset using WithConditions; if none of the source objects have a condition within the scope of the merge +// operation, no target condition is generated. +func SetAggregate(to Setter, targetCondition string, from []Getter, options ...MergeOption) { + Set(to, aggregate(from, targetCondition, options...)) +} + +// Delete deletes the condition with the given type. +func Delete(to Setter, t string) { + if to == nil { + return + } + + conditions := to.GetConditions() + newConditions := make([]metav1.Condition, 0, len(conditions)) + for _, condition := range conditions { + if condition.Type != t { + newConditions = append(newConditions, condition) + } + } + to.SetConditions(newConditions) +} + +// conditionWeights defines the weight of condition types that have priority in lexicographicLess. +// TODO(hidde): given Reconciling is an abnormality-true type, and SHOULD only be present on the +// resource if applicable, I think it actually should have a higher priority than Ready. +var conditionWeights = map[string]int{ + meta.StalledCondition: 0, + meta.ReadyCondition: 1, + meta.ReconcilingCondition: 2, +} + +// lexicographicLess returns true if a condition is less than another with regards to the to order of conditions +// designed for convenience of the consumer, i.e. kubectl. The condition types in conditionWeights always go first, +// sorted by their defined weight, followed by all the other conditions sorted lexicographically by Type. +func lexicographicLess(i, j *metav1.Condition) bool { + w1, ok1 := conditionWeights[i.Type] + w2, ok2 := conditionWeights[j.Type] + switch { + case ok1 && ok2: + return w1 < w2 + case ok1, ok2: + return !ok2 + default: + return i.Type < j.Type + } +} + +// hasSameState returns true if a condition has the same state of another; state is defined by the union of following +// fields: Type, Status, Reason, and Message (it excludes LastTransitionTime and ObservedGeneration). +func hasSameState(i, j *metav1.Condition) bool { + return i.Type == j.Type && + i.Status == j.Status && + i.Reason == j.Reason && + i.Message == j.Message +} diff --git a/runtime/conditions/setter_test.go b/runtime/conditions/setter_test.go new file mode 100644 index 00000000..8b0f9a90 --- /dev/null +++ b/runtime/conditions/setter_test.go @@ -0,0 +1,310 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/setter_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + "time" + + "github.com/fluxcd/pkg/apis/meta" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHasSameState(t *testing.T) { + g := NewWithT(t) + + // same condition + true2 := true1.DeepCopy() + g.Expect(hasSameState(true1, true2)).To(BeTrue()) + + // different LastTransitionTime does not impact state + true2 = true1.DeepCopy() + true2.LastTransitionTime = metav1.NewTime(time.Date(1900, time.November, 10, 23, 0, 0, 0, time.UTC)) + g.Expect(hasSameState(true1, true2)).To(BeTrue()) + + // different ObservedGeneration does not impact state + true2 = true1.DeepCopy() + true2.ObservedGeneration = 1 + g.Expect(hasSameState(true1, true2)).To(BeTrue()) + + // different Type, Status, Reason, and Message determine + // different state + true2 = true1.DeepCopy() + true2.Type = "another type" + g.Expect(hasSameState(true1, true2)).To(BeFalse()) + + true2 = true1.DeepCopy() + true2.Status = metav1.ConditionFalse + g.Expect(hasSameState(true1, true2)).To(BeFalse()) + + true2 = true1.DeepCopy() + true2.Message = "another message" + g.Expect(hasSameState(true1, true2)).To(BeFalse()) +} + +func TestLexicographicLess(t *testing.T) { + g := NewWithT(t) + + // alphabetical order of Type is respected + a := TrueCondition("A", "", "") + b := TrueCondition("B", "", "") + g.Expect(lexicographicLess(a, b)).To(BeTrue()) + + a = TrueCondition("B", "", "") + b = TrueCondition("A", "", "") + g.Expect(lexicographicLess(a, b)).To(BeFalse()) + + // Stalled, Ready, and Reconciling conditions are threaded as an + // exception and always go first. + stalled := TrueCondition(meta.StalledCondition, "", "") + ready := FalseCondition(meta.ReadyCondition, "", "") + reconciling := TrueCondition(meta.ReconcilingCondition, "", "") + + g.Expect(lexicographicLess(stalled, ready)).To(BeTrue()) + g.Expect(lexicographicLess(ready, stalled)).To(BeFalse()) + + g.Expect(lexicographicLess(ready, reconciling)).To(BeTrue()) + g.Expect(lexicographicLess(reconciling, ready)).To(BeFalse()) + + g.Expect(lexicographicLess(stalled, reconciling)).To(BeTrue()) + g.Expect(lexicographicLess(reconciling, stalled)).To(BeFalse()) + + g.Expect(lexicographicLess(ready, b)).To(BeTrue()) + g.Expect(lexicographicLess(b, ready)).To(BeFalse()) +} + +func TestSet(t *testing.T) { + a := TrueCondition("a", "", "") + b := TrueCondition("b", "", "") + ready := TrueCondition(meta.ReadyCondition, "", "") + + tests := []struct { + name string + to Setter + condition *metav1.Condition + want []metav1.Condition + }{ + { + name: "Set adds a condition", + to: setterWithConditions(), + condition: a, + want: conditionList(a), + }, + { + name: "Set adds more conditions", + to: setterWithConditions(a), + condition: b, + want: conditionList(a, b), + }, + { + name: "Set does not duplicate existing conditions", + to: setterWithConditions(a, b), + condition: a, + want: conditionList(a, b), + }, + { + name: "Set sorts conditions in lexicographic order", + to: setterWithConditions(b, a), + condition: ready, + want: conditionList(ready, a, b), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + Set(tt.to, tt.condition) + + g.Expect(tt.to.GetConditions()).To(haveSameConditionsOf(tt.want)) + }) + } +} + +func TestSetLastTransitionTime(t *testing.T) { + x := metav1.Date(2012, time.January, 1, 12, 15, 30, 5e8, time.UTC) + + foo := FalseCondition("foo", "reason foo", "message foo") + fooWithLastTransitionTime := FalseCondition("foo", "reason foo", "message foo") + fooWithLastTransitionTime.LastTransitionTime = x + fooWithAnotherState := TrueCondition("foo", "", "") + + tests := []struct { + name string + to Setter + new *metav1.Condition + LastTransitionTimeCheck func(*WithT, metav1.Time) + }{ + { + name: "Set a condition that does not exists should set the last transition time if not defined", + to: setterWithConditions(), + new: foo, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).ToNot(BeZero()) + }, + }, + { + name: "Set a condition that does not exists should preserve the last transition time if defined", + to: setterWithConditions(), + new: fooWithLastTransitionTime, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).To(Equal(x)) + }, + }, + { + name: "Set a condition that already exists with the same state should preserves the last transition time", + to: setterWithConditions(fooWithLastTransitionTime), + new: foo, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).To(Equal(x)) + }, + }, + { + name: "Set a condition that already exists but with different state should changes the last transition time", + to: setterWithConditions(fooWithLastTransitionTime), + new: fooWithAnotherState, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).ToNot(Equal(x)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + Set(tt.to, tt.new) + + tt.LastTransitionTimeCheck(g, Get(tt.to, "foo").LastTransitionTime) + }) + } +} + +func TestMarkMethods(t *testing.T) { + g := NewWithT(t) + + obj := &fake{} + + // test MarkTrue + MarkTrue(obj, "conditionFoo", "reasonFoo", "messageFoo") + g.Expect(Get(obj, "conditionFoo")).To(HaveSameStateOf(&metav1.Condition{ + Type: "conditionFoo", + Status: metav1.ConditionTrue, + Reason: "reasonFoo", + Message: "messageFoo", + })) + + // test MarkFalse + MarkFalse(obj, "conditionBar", "reasonBar", "messageBar") + g.Expect(Get(obj, "conditionBar")).To(HaveSameStateOf(&metav1.Condition{ + Type: "conditionBar", + Status: metav1.ConditionFalse, + Reason: "reasonBar", + Message: "messageBar", + })) + + // test MarkUnknown + MarkUnknown(obj, "conditionBaz", "reasonBaz", "messageBaz") + g.Expect(Get(obj, "conditionBaz")).To(HaveSameStateOf(&metav1.Condition{ + Type: "conditionBaz", + Status: metav1.ConditionUnknown, + Reason: "reasonBaz", + Message: "messageBaz", + })) +} + +func TestSetSummary(t *testing.T) { + g := NewWithT(t) + target := setterWithConditions(TrueCondition("foo", "", "")) + + SetSummary(target, "test") + + g.Expect(Has(target, "test")).To(BeTrue()) +} + +func TestSetMirror(t *testing.T) { + g := NewWithT(t) + source := getterWithConditions(TrueCondition(meta.ReadyCondition, "", "")) + target := setterWithConditions() + + SetMirror(target, "foo", source) + + g.Expect(Has(target, "foo")).To(BeTrue()) +} + +func TestSetAggregate(t *testing.T) { + g := NewWithT(t) + source1 := getterWithConditions(TrueCondition(meta.ReadyCondition, "", "")) + source2 := getterWithConditions(TrueCondition(meta.ReadyCondition, "", "")) + target := setterWithConditions() + + SetAggregate(target, "foo", []Getter{source1, source2}) + + g.Expect(Has(target, "foo")).To(BeTrue()) +} + +func setterWithConditions(conditions ...*metav1.Condition) Setter { + obj := &fake{} + obj.SetConditions(conditionList(conditions...)) + return obj +} + +func haveSameConditionsOf(expected []metav1.Condition) types.GomegaMatcher { + return &ConditionsMatcher{ + Expected: expected, + } +} + +type ConditionsMatcher struct { + Expected []metav1.Condition +} + +func (matcher *ConditionsMatcher) Match(actual interface{}) (success bool, err error) { + actualConditions, ok := actual.([]metav1.Condition) + if !ok { + return false, errors.New("Value should be a conditions list") + } + + if len(actualConditions) != len(matcher.Expected) { + return false, nil + } + + for i := range actualConditions { + if !hasSameState(&actualConditions[i], &matcher.Expected[i]) { + return false, nil + } + } + return true, nil +} + +func (matcher *ConditionsMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, "to have the same conditions of", matcher.Expected) +} +func (matcher *ConditionsMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, "not to have the same conditions of", matcher.Expected) +} diff --git a/runtime/conditions/unstructured.go b/runtime/conditions/unstructured.go new file mode 100644 index 00000000..1259ca84 --- /dev/null +++ b/runtime/conditions/unstructured.go @@ -0,0 +1,120 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/unstructured.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var ( + ErrUnstructuredFieldNotFound = fmt.Errorf("field not found") +) + +// UnstructuredGetter return a Getter object that can read conditions from an Unstructured object. +// +// IMPORTANT: This method should be used only with types implementing status conditions with a metav1.Condition type. +func UnstructuredGetter(u *unstructured.Unstructured) Getter { + return &unstructuredWrapper{Unstructured: u} +} + +// UnstructuredSetter return a Setter object that can set conditions from an Unstructured object. +// +// IMPORTANT: This method should be used only with types implementing status conditions with a metav1.Condition type. +func UnstructuredSetter(u *unstructured.Unstructured) Setter { + return &unstructuredWrapper{Unstructured: u} +} + +// UnstructuredUnmarshalField is a wrapper around JSON and Unstructured objects to decode and copy a specific field +// value into an object. +func UnstructuredUnmarshalField(u *unstructured.Unstructured, v interface{}, fields ...string) error { + value, found, err := unstructured.NestedFieldNoCopy(u.Object, fields...) + if err != nil { + return errors.Wrapf(err, "failed to retrieve field %q from %q", strings.Join(fields, "."), u.GroupVersionKind()) + } + if !found || value == nil { + return ErrUnstructuredFieldNotFound + } + valueBytes, err := json.Marshal(value) + if err != nil { + return errors.Wrapf(err, "failed to json-encode field %q value from %q", strings.Join(fields, "."), u.GroupVersionKind()) + } + if err := json.Unmarshal(valueBytes, v); err != nil { + return errors.Wrapf(err, "failed to json-decode field %q value from %q", strings.Join(fields, "."), u.GroupVersionKind()) + } + return nil +} + +type unstructuredWrapper struct { + *unstructured.Unstructured +} + +// GetConditions returns the list of conditions from an Unstructured object. +// +// NOTE: Due to the constraints of JSON-unmarshal, this operation is to be considered best effort. +// In more details: +// - Errors during JSON-unmarshal are ignored and a empty collection list is returned. +// - It's not possible to detect if the object has an empty condition list or if it does not implement conditions; +// in both cases the operation returns an empty slice. +// - If the object doesn't implement status conditions as defined in GitOps Toolkit API, +// JSON-unmarshal matches incoming object keys to the keys; this can lead to to conditions values partially set. +func (c *unstructuredWrapper) GetConditions() []metav1.Condition { + conditions := []metav1.Condition{} + if err := UnstructuredUnmarshalField(c.Unstructured, &conditions, "status", "conditions"); err != nil { + return nil + } + return conditions +} + +// SetConditions set the conditions into an Unstructured object. +// +// NOTE: Due to the constraints of JSON-unmarshal, this operation is to be considered best effort. +// In more details: +// - Errors during JSON-unmarshal are ignored and a empty collection list is returned. +// - It's not possible to detect if the object has an empty condition list or if it does not implement conditions; +// in both cases the operation returns an empty slice is returned. +func (c *unstructuredWrapper) SetConditions(conditions []metav1.Condition) { + v := make([]interface{}, 0, len(conditions)) + for i := range conditions { + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&conditions[i]) + if err != nil { + log.Log.Error(err, "Failed to convert Condition to unstructured map. This error shouldn't have occurred, please file an issue.", "groupVersionKind", c.GroupVersionKind(), "name", c.GetName(), "namespace", c.GetNamespace()) + continue + } + v = append(v, m) + } + // unstructured.SetNestedField returns an error only if value cannot be set because one of + // the nesting levels is not a map[string]interface{}; this is not the case so the error should never happen here. + err := unstructured.SetNestedField(c.Unstructured.Object, v, "status", "conditions") + if err != nil { + log.Log.Error(err, "Failed to set Conditions on unstructured object. This error shouldn't have occurred, please file an issue.", "groupVersionKind", c.GroupVersionKind(), "name", c.GetName(), "namespace", c.GetNamespace()) + } +} diff --git a/runtime/conditions/unstructured_test.go b/runtime/conditions/unstructured_test.go new file mode 100644 index 00000000..43d6d4c6 --- /dev/null +++ b/runtime/conditions/unstructured_test.go @@ -0,0 +1,106 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/7478817225e0a75acb6e14fc7b438231578073d2/util/conditions/unstructured_test.go, +and initially adapted to work with the `metav1.Condition` and `metav1.ConditionStatus` types. +More concretely, this includes the removal of "condition severity" related functionalities, as this is not supported by +the `metav1.Condition` type. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestUnstructuredGetConditions(t *testing.T) { + g := NewWithT(t) + + scheme := runtime.NewScheme() + g.Expect(corev1.AddToScheme(scheme)).To(Succeed()) + scheme.AddKnownTypes(fakeSchemeGroupVersion, + &fake{}, + ) + + // GetConditions should return conditions from an unstructured object + c := &fake{} + c.SetConditions(conditionList(true1)) + u := &unstructured.Unstructured{} + g.Expect(scheme.Convert(c, u, nil)).To(Succeed()) + + g.Expect(UnstructuredGetter(u).GetConditions()).To(haveSameConditionsOf(conditionList(true1))) + + // GetConditions should return nil for an unstructured object with empty conditions + c = &fake{} + u = &unstructured.Unstructured{} + g.Expect(scheme.Convert(c, u, nil)).To(Succeed()) + + g.Expect(UnstructuredGetter(u).GetConditions()).To(BeNil()) + + // GetConditions should return nil for an unstructured object without conditions + e := &corev1.Endpoints{} + u = &unstructured.Unstructured{} + g.Expect(scheme.Convert(e, u, nil)).To(Succeed()) + + g.Expect(UnstructuredGetter(u).GetConditions()).To(BeNil()) + + // GetConditions should return conditions from an unstructured object with a different type of conditions. + p := &corev1.Pod{Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: "foo", + Status: "foo", + LastProbeTime: metav1.Time{}, + LastTransitionTime: metav1.Time{}, + Reason: "foo", + Message: "foo", + }, + }, + }} + u = &unstructured.Unstructured{} + g.Expect(scheme.Convert(p, u, nil)).To(Succeed()) + + g.Expect(UnstructuredGetter(u).GetConditions()).To(HaveLen(1)) +} + +func TestUnstructuredSetConditions(t *testing.T) { + g := NewWithT(t) + + // gets an unstructured with empty conditions + scheme := runtime.NewScheme() + g.Expect(corev1.AddToScheme(scheme)).To(Succeed()) + scheme.AddKnownTypes(fakeSchemeGroupVersion, + &fake{}, + ) + + c := &fake{} + u := &unstructured.Unstructured{} + g.Expect(scheme.Convert(c, u, nil)).To(Succeed()) + + // set conditions + conditions := conditionList(true1, false1) + + s := UnstructuredSetter(u) + s.SetConditions(conditions) + g.Expect(s.GetConditions()).To(Equal(conditions)) +} diff --git a/runtime/controller/doc.go b/runtime/controller/doc.go index 6c60d446..70203fe3 100644 --- a/runtime/controller/doc.go +++ b/runtime/controller/doc.go @@ -14,6 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package controller offers embeddable structs for putting in your -// controller, to help with conforming to GitOps Toolkit conventions. +// Package controller offers embeddable structs for use in your controller and underlying reconcilers, to help with +// conforming to GitOps Toolkit conventions. package controller diff --git a/runtime/controller/events.go b/runtime/controller/events.go index d2866338..b5dac211 100644 --- a/runtime/controller/events.go +++ b/runtime/controller/events.go @@ -21,35 +21,37 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" kuberecorder "k8s.io/client-go/tools/record" "k8s.io/client-go/tools/reference" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/fluxcd/pkg/runtime/events" ) -// Events is a helper struct that adds the capability of sending -// events to the Kubernetes API and to the GitOps Toolkit notification -// controller. You use it by embedding it in your reconciler struct: +// Events is a helper struct that adds the capability of sending events to the Kubernetes API and an external event +// recorder, like the GitOps Toolkit notification-controller. // -// type MyTypeReconciler { -// client.Client -// // ... etc. -// controller.Events -// } +// Use it by embedding it in your reconciler struct: // -// You initialise a suitable value with MakeEvents; in most cases the -// value only needs to be initialized once per controller, as the -// specialised logger and object reference data are gathered from the -// arguments provided to the Eventf method. +// type MyTypeReconciler { +// client.Client +// // ... etc. +// controller.Events +// } +// +// Use MakeEvents to create a working Events value; in most cases the value needs to be initialised just once per +// controller, as the specialised logger and object reference data are gathered from the arguments provided to the +// Eventf method. type Events struct { Scheme *runtime.Scheme EventRecorder kuberecorder.EventRecorder ExternalEventRecorder *events.Recorder } +// MakeEvents creates a new Events, with the Events.Scheme set to that of the given mgr and a newly initialised +// Events.EventRecorder for the given controllerName. func MakeEvents(mgr ctrl.Manager, controllerName string, ext *events.Recorder) Events { return Events{ Scheme: mgr.GetScheme(), @@ -58,20 +60,13 @@ func MakeEvents(mgr ctrl.Manager, controllerName string, ext *events.Recorder) E } } -type runtimeAndMetaObject interface { - runtime.Object - metav1.Object -} - -// Event emits a Kubernetes event, and forwards the event to the -// notification controller if configured. -func (e Events) Event(ctx context.Context, obj runtimeAndMetaObject, metadata map[string]string, severity, reason, msg string) { +// Event emits a Kubernetes event, and forwards the event to the ExternalEventRecorder if configured. +func (e Events) Event(ctx context.Context, obj client.Object, metadata map[string]string, severity, reason, msg string) { e.Eventf(ctx, obj, metadata, severity, reason, msg) } -// Eventf emits a Kubernetes event, and forwards the event to the -// notification controller if configured. -func (e Events) Eventf(ctx context.Context, obj runtimeAndMetaObject, metadata map[string]string, severity, reason, msgFmt string, args ...interface{}) { +// Eventf emits a Kubernetes event, and forwards the event to the ExternalEventRecorder if configured. +func (e Events) Eventf(ctx context.Context, obj client.Object, metadata map[string]string, severity, reason, msgFmt string, args ...interface{}) { if e.EventRecorder != nil { e.EventRecorder.Eventf(obj, severityToEventType(severity), reason, msgFmt, args...) } @@ -88,6 +83,8 @@ func (e Events) Eventf(ctx context.Context, obj runtimeAndMetaObject, metadata m } } +// severityToEventType maps the given severity string to a corev1 EventType. +// In case of an unrecognised severity, EventTypeNormal is returned. func severityToEventType(severity string) string { switch severity { case events.EventSeverityError: diff --git a/runtime/controller/metrics.go b/runtime/controller/metrics.go index 20f540cd..1babef4b 100644 --- a/runtime/controller/metrics.go +++ b/runtime/controller/metrics.go @@ -20,9 +20,8 @@ import ( "context" "time" + "github.com/fluxcd/pkg/runtime/conditions" "github.com/go-logr/logr" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/reference" ctrl "sigs.k8s.io/controller-runtime" @@ -32,28 +31,32 @@ import ( "github.com/fluxcd/pkg/runtime/metrics" ) -// Metrics adds the capability for recording GOTK-standard metrics to -// a reconciler. Use by embedding into the reconciler struct: +// Metrics is a helper struct that adds the capability for recording GitOps Toolkit standard metrics to a reconciler. // -// type MyTypeReconciler struct { -// client.Client -// // ... -// controller.Metrics -// } +// Use it by embedding it in your reconciler struct: // -// then you can call either or both of RecordDuration and -// RecordReadinessMetric. API types used in GOTK will usually -// already be suitable for passing (as a pointer) as the second -// argument to `RecordReadinessMetric`. +// type MyTypeReconciler { +// client.Client +// // ... etc. +// controller.Metrics +// } // -// When initialising controllers in main.go, use MustMakeMetrics to -// create a working Metrics value; you can supply the same value to -// all reconcilers. +// Following the GitOps Toolkit conventions, API types used in GOTK SHOULD implement conditions.Getter to work with +// status condition types, and this convention MUST be followed to be able to record metrics using this helper. +// +// Use MustMakeMetrics to create a working Metrics value; you can supply the same value to all reconcilers. +// +// Once initialised, metrics can be recorded by calling one of the available `Record*` methods. type Metrics struct { Scheme *runtime.Scheme MetricsRecorder *metrics.Recorder } +// MustMakeMetrics creates a new Metrics with a new metrics.Recorder, and the Metrics.Scheme set to that of the given +// mgr. +// It attempts to register the metrics collectors in the controller-runtime metrics registry, which panics upon the +// first registration that causes an error. Which usually happens if you try to initialise a Metrics value twice for +// your controller. func MustMakeMetrics(mgr ctrl.Manager) Metrics { metricsRecorder := metrics.NewRecorder() crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) @@ -64,7 +67,8 @@ func MustMakeMetrics(mgr ctrl.Manager) Metrics { } } -func (m Metrics) RecordDuration(ctx context.Context, obj readinessMetricsable, startTime time.Time) { +// RecordDuration records the duration of a reconcile attempt for the given obj based on the given startTime. +func (m Metrics) RecordDuration(ctx context.Context, obj conditions.Getter, startTime time.Time) { if m.MetricsRecorder != nil { ref, err := reference.GetReference(m.Scheme, obj) if err != nil { @@ -75,7 +79,8 @@ func (m Metrics) RecordDuration(ctx context.Context, obj readinessMetricsable, s } } -func (m Metrics) RecordSuspend(ctx context.Context, obj readinessMetricsable, suspend bool) { +// RecordSuspend records the suspension of the given obj based on the given suspend value. +func (m Metrics) RecordSuspend(ctx context.Context, obj conditions.Getter, suspend bool) { if m.MetricsRecorder != nil { ref, err := reference.GetReference(m.Scheme, obj) if err != nil { @@ -86,17 +91,23 @@ func (m Metrics) RecordSuspend(ctx context.Context, obj readinessMetricsable, su } } -type readinessMetricsable interface { - runtime.Object - metav1.Object - meta.ObjectWithStatusConditions +// RecordReadiness records the meta.ReadyCondition status for the given obj. +func (m Metrics) RecordReadiness(ctx context.Context, obj conditions.Getter) { + m.RecordCondition(ctx, obj, meta.ReadyCondition) } -func (m Metrics) RecordReadinessMetric(ctx context.Context, obj readinessMetricsable) { - m.RecordConditionMetric(ctx, obj, meta.ReadyCondition) +// RecordReconciling records the meta.ReconcilingCondition status for the given obj. +func (m Metrics) RecordReconciling(ctx context.Context, obj conditions.Getter) { + m.RecordCondition(ctx, obj, meta.ReconcilingCondition) } -func (m Metrics) RecordConditionMetric(ctx context.Context, obj readinessMetricsable, conditionType string) { +// RecordStalled records the meta.StalledCondition status for the given obj. +func (m Metrics) RecordStalled(ctx context.Context, obj conditions.Getter) { + m.RecordCondition(ctx, obj, meta.StalledCondition) +} + +// RecordCondition records the status of the given conditionType for the given obj. +func (m Metrics) RecordCondition(ctx context.Context, obj conditions.Getter, conditionType string) { if m.MetricsRecorder == nil { return } @@ -105,12 +116,9 @@ func (m Metrics) RecordConditionMetric(ctx context.Context, obj readinessMetrics logr.FromContextOrDiscard(ctx).Error(err, "unable to get object reference to record condition metric") return } - rc := apimeta.FindStatusCondition(*obj.GetStatusConditions(), conditionType) + rc := conditions.Get(obj, conditionType) if rc == nil { - rc = &metav1.Condition{ - Type: conditionType, - Status: metav1.ConditionUnknown, - } + rc = conditions.UnknownCondition(conditionType, "", "") } m.MetricsRecorder.RecordCondition(*ref, *rc, !obj.GetDeletionTimestamp().IsZero()) } diff --git a/runtime/dependency/doc.go b/runtime/dependency/doc.go new file mode 100644 index 00000000..c064e750 --- /dev/null +++ b/runtime/dependency/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2020 The Flux 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 dependency contains an utility for sorting a set of Kubernetes resource objects that implement the +// Dependent interface. +package dependency diff --git a/runtime/dependency/sort.go b/runtime/dependency/sort.go index e5049ad5..93e93735 100644 --- a/runtime/dependency/sort.go +++ b/runtime/dependency/sort.go @@ -20,50 +20,33 @@ import ( "fmt" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/internal/tarjan" ) -// Dependent provides an interface for resources that maintain -// CrossNamespaceDependencyReference list. +// Dependent interface defines methods that a Kubernetes resource object should implement in order to use the dependency +// package for ordering dependencies. type Dependent interface { - // GetDependsOn returns the Dependent's types.NamespacedName, - // and the CrossNamespaceDependencyReference slice it depends on. - GetDependsOn() (types.NamespacedName, []CrossNamespaceDependencyReference) + client.Object + meta.ObjectWithDependencies } -// CrossNamespaceDependencyReference holds the reference to a dependency. -type CrossNamespaceDependencyReference struct { - // Namespace holds the namespace reference of a dependency. - // +optional - Namespace string `json:"namespace,omitempty"` - - // Name holds the name reference of a dependency. - // +required - Name string `json:"name"` -} - -func (r CrossNamespaceDependencyReference) String() string { - if r.Namespace == "" { - return r.Name - } - return fmt.Sprintf("%s%c%s", r.Namespace, types.Separator, r.Name) -} - -// CircularDependencyError contains the circular dependency chains -// that were detected while sorting the Dependent dependencies. +// CircularDependencyError contains the circular dependency chains that were detected while sorting the Dependent +// dependencies. type CircularDependencyError [][]string func (e CircularDependencyError) Error() string { return fmt.Sprintf("circular dependencies: %v", [][]string(e)) } -// Sort sorts the Dependent slice based on their listed -// dependencies using Tarjan's strongly connected components algorithm. -func Sort(d []Dependent) ([]CrossNamespaceDependencyReference, error) { +// Sort sorts the Dependent slice based on their listed dependencies using Tarjan's strongly connected components +// algorithm. +func Sort(d []Dependent) ([]meta.NamespacedObjectReference, error) { g, l := buildGraph(d) sccs := tarjan.SCC(g) - var sorted []CrossNamespaceDependencyReference + var sorted []meta.NamespacedObjectReference var circular CircularDependencyError for i := 0; i < len(sccs); i++ { s := sccs[i] @@ -84,18 +67,22 @@ func Sort(d []Dependent) ([]CrossNamespaceDependencyReference, error) { return sorted, nil } -func buildGraph(d []Dependent) (tarjan.Graph, map[string]CrossNamespaceDependencyReference) { +func buildGraph(d []Dependent) (tarjan.Graph, map[string]meta.NamespacedObjectReference) { g := make(tarjan.Graph) - l := make(map[string]CrossNamespaceDependencyReference) + l := make(map[string]meta.NamespacedObjectReference) for i := 0; i < len(d); i++ { - name, deps := d[i].GetDependsOn() - g[name.String()] = buildEdges(deps, name.Namespace) - l[name.String()] = CrossNamespaceDependencyReference(name) + ref := meta.NamespacedObjectReference{ + Namespace: d[i].GetNamespace(), + Name: d[i].GetName(), + } + deps := d[i].GetDependsOn() + g[namespacedNameObjRef(ref)] = buildEdges(deps, ref.Namespace) + l[namespacedNameObjRef(ref)] = ref } return g, l } -func buildEdges(d []CrossNamespaceDependencyReference, defaultNamespace string) tarjan.Edges { +func buildEdges(d []meta.NamespacedObjectReference, defaultNamespace string) tarjan.Edges { if len(d) == 0 { return nil } @@ -104,7 +91,11 @@ func buildEdges(d []CrossNamespaceDependencyReference, defaultNamespace string) if v.Namespace == "" { v.Namespace = defaultNamespace } - e[v.String()] = struct{}{} + e[namespacedNameObjRef(v)] = struct{}{} } return e } + +func namespacedNameObjRef(ref meta.NamespacedObjectReference) string { + return ref.Namespace + string(types.Separator) + ref.Name +} diff --git a/runtime/dependency/sort_test.go b/runtime/dependency/sort_test.go index 84242649..ef93a0dd 100644 --- a/runtime/dependency/sort_test.go +++ b/runtime/dependency/sort_test.go @@ -20,34 +20,38 @@ import ( "reflect" "testing" - "k8s.io/apimachinery/pkg/types" + "github.com/fluxcd/pkg/apis/meta" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type MockDependent struct { - NamespacedName types.NamespacedName - DependsOn []CrossNamespaceDependencyReference + corev1.Node + DependsOn []meta.NamespacedObjectReference } -func (d MockDependent) GetDependsOn() (types.NamespacedName, []CrossNamespaceDependencyReference) { - return d.NamespacedName, d.DependsOn +func (d MockDependent) GetDependsOn() []meta.NamespacedObjectReference { + return d.DependsOn } func TestDependencySort(t *testing.T) { tests := []struct { name string d []Dependent - want []CrossNamespaceDependencyReference + want []meta.NamespacedObjectReference wantErr bool }{ { "simple", []Dependent{ - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "frontend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "frontend", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "linkerd", Name: "linkerd", @@ -58,18 +62,22 @@ func TestDependencySort(t *testing.T) { }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "linkerd", - Name: "linkerd", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "linkerd", + Name: "linkerd", + }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "backend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "backend", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "linkerd", Name: "linkerd", @@ -77,7 +85,7 @@ func TestDependencySort(t *testing.T) { }, }, }, - []CrossNamespaceDependencyReference{ + []meta.NamespacedObjectReference{ { Namespace: "linkerd", Name: "linkerd", @@ -96,36 +104,42 @@ func TestDependencySort(t *testing.T) { { "circular dependency", []Dependent{ - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "dependency", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "dependency", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "default", Name: "endless", }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "endless", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "endless", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "default", Name: "circular", }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "circular", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "circular", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "default", Name: "dependency", @@ -139,25 +153,29 @@ func TestDependencySort(t *testing.T) { { "missing namespace", []Dependent{ - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "application", - Name: "backend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "application", + Name: "backend", + }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "application", - Name: "frontend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "application", + Name: "frontend", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Name: "backend", }, }, }, }, - []CrossNamespaceDependencyReference{ + []meta.NamespacedObjectReference{ { Namespace: "application", Name: "backend", @@ -186,34 +204,40 @@ func TestDependencySort(t *testing.T) { func TestDependencySort_DeadEnd(t *testing.T) { d := []Dependent{ - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "backend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "backend", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "default", Name: "common", }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "frontend", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "frontend", + }, }, - DependsOn: []CrossNamespaceDependencyReference{ + DependsOn: []meta.NamespacedObjectReference{ { Namespace: "default", Name: "infra", }, }, }, - MockDependent{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "common", + &MockDependent{ + Node: corev1.Node{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "common", + }, }, }, } diff --git a/runtime/errors/doc.go b/runtime/errors/doc.go new file mode 100644 index 00000000..1f7099ff --- /dev/null +++ b/runtime/errors/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 errors contains generic controller and reconciler runtime errors to be used by GitOps Toolkit components. +package errors diff --git a/runtime/errors/errors.go b/runtime/errors/errors.go index 81efee61..9788a185 100644 --- a/runtime/errors/errors.go +++ b/runtime/errors/errors.go @@ -22,9 +22,8 @@ import ( "k8s.io/apimachinery/pkg/types" ) -// ReconciliationError is returned on a reconciliation failure for a resource, -// it includes the Kind and NamespacedName of the resource the reconciliation -// was performed for, and the underlying Err. +// ReconciliationError is describes a generic reconciliation error for a resource, it includes the Kind and NamespacedName +// of the resource, and any underlying Err. type ReconciliationError struct { Kind string NamespacedName types.NamespacedName @@ -39,39 +38,43 @@ func (e *ReconciliationError) Unwrap() error { return e.Err } -// SourceNotReadyError is returned when a source is not in a ready condition -// during a reconciliation attempt, it includes the Kind and NamespacedName of -// the source. -type SourceNotReadyError struct { +// ResourceNotReadyError describes an error in which a referred resource is not in a meta.ReadyCondition state, +// it includes the Kind and NamespacedName, and any underlying Err. +type ResourceNotReadyError struct { Kind string NamespacedName types.NamespacedName + Err error +} + +func (e *ResourceNotReadyError) Error() string { + return fmt.Sprintf("%s resource '%s' is not ready", e.Kind, e.NamespacedName.String()) } -func (e *SourceNotReadyError) Error() string { - return fmt.Sprintf("%s source '%s' is not ready", e.Kind, e.NamespacedName.String()) +func (e *ResourceNotReadyError) Unwrap() error { + return e.Err } -// SourceNotFoundError is returned if a referred source was not found, it -// includes the Kind and NamespacedName of the source. -type SourceNotFoundError struct { +// ResourceNotFoundError describes an error in which a referred resource could not be found, +// it includes the Kind and NamespacedName, and any underlying Err. +type ResourceNotFoundError struct { Kind string NamespacedName types.NamespacedName + Err error } -func (e *SourceNotFoundError) Error() string { - return fmt.Sprintf("%s source '%s' does not exist", e.Kind, e.NamespacedName.String()) +func (e *ResourceNotFoundError) Error() string { + return fmt.Sprintf("%s resource '%s' could not be found", e.Kind, e.NamespacedName.String()) } -// UnsupportedSourceKindError is returned if a referred source is of an -// unsupported kind, it includes the Kind and Namespace of the source, and MAY -// contain a string slice of SupportedKinds. -type UnsupportedSourceKindError struct { +// UnsupportedResourceKindError describes an error in which a referred resource is of an unsupported kind, +// it includes the Kind and NamespacedName of the resource, and any underlying Err. +type UnsupportedResourceKindError struct { Kind string NamespacedName types.NamespacedName SupportedKinds []string } -func (e *UnsupportedSourceKindError) Error() string { +func (e *UnsupportedResourceKindError) Error() string { err := fmt.Sprintf("source '%s' with kind %s is not supported", e.NamespacedName.String(), e.Kind) if len(e.SupportedKinds) == 0 { return err @@ -79,53 +82,8 @@ func (e *UnsupportedSourceKindError) Error() string { return fmt.Sprintf("%s (must be one of: %q)", err, e.SupportedKinds) } -// ArtifactAcquisitionError is returned if the artifact of a source could not be -// acquired, it includes the Kind and NamespacedName of the source that -// advertised the artifact, and MAY contain an underlying Err. -type ArtifactAcquisitionError struct { - Kind string - NamespacedName types.NamespacedName - Err error -} - -func (e *ArtifactAcquisitionError) Error() string { - err := fmt.Sprintf("failed to acquire %s artifact from '%s'", e.Kind, e.NamespacedName.String()) - if e.Err == nil { - return err - } - return fmt.Sprintf("%s: %v", err, e.Err) -} - -func (e *ArtifactAcquisitionError) Unwrap() error { - return e.Err -} - -// DependencyNotReadyError is returned if a referred dependency resource is not -// in a ready condition, it includes the Kind and NamespacedName of the -// dependency. -type DependencyNotReadyError struct { - Kind string - NamespacedName types.NamespacedName -} - -func (e *DependencyNotReadyError) Error() string { - return fmt.Sprintf("dependency '%s' of kind %s is not ready", e.NamespacedName.String(), e.Kind) -} - -// DependencyNotFoundError is returned if a referred dependency resource was not -// found, it includes the Kind and NamespacedName of the dependency. -type DependencyNotFoundError struct { - Kind string - NamespacedName types.NamespacedName -} - -func (e *DependencyNotFoundError) Error() string { - return fmt.Sprintf("dependency '%s' of kind %s does not exist", e.NamespacedName, e.Kind) -} - -// GarbageCollectionError is returned on a garbage collection failure for a -// resource, it includes the Kind and NamespacedName the garbage collection -// failed for, and the underlying Err. +// GarbageCollectionError is describes a garbage collection error for a resources, it includes the Kind and +// NamespacedName of the resource, and the underlying Err. type GarbageCollectionError struct { Kind string NamespacedName types.NamespacedName diff --git a/runtime/events/doc.go b/runtime/events/doc.go new file mode 100644 index 00000000..76d4ff88 --- /dev/null +++ b/runtime/events/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 events provides a Recorder and additional helpers to record Kubernetes Events on a external HTTP endpoint. +package events diff --git a/runtime/events/event.go b/runtime/events/event.go index 57b486c9..ab5cab72 100644 --- a/runtime/events/event.go +++ b/runtime/events/event.go @@ -21,7 +21,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// Valid values for event severity. +// These constants define valid event severity values. const ( // EventSeverityInfo represents an informational event, usually // informing about changes. @@ -31,8 +31,8 @@ const ( EventSeverityError string = "error" ) -// +kubebuilder:object:generate=true // Event is a report of an event issued by a controller. +// +kubebuilder:object:generate=true type Event struct { // The object that this event is about. // +required diff --git a/runtime/events/recorder.go b/runtime/events/recorder.go index 1c0cb3ae..ab611de5 100644 --- a/runtime/events/recorder.go +++ b/runtime/events/recorder.go @@ -38,12 +38,12 @@ type Recorder struct { // Name of the controller that emits events. ReportingController string - // Retryable HTTP client + // Retryable HTTP client. Client *retryablehttp.Client } // NewRecorder creates an event Recorder with default settings. -// The recorder performs automatic retries for connection errors and 500-range response code. +// The recorder performs automatic retries for connection errors and 500-range response codes. func NewRecorder(webhook, reportingController string) (*Recorder, error) { if _, err := url.Parse(webhook); err != nil { return nil, err @@ -77,8 +77,7 @@ func (r *Recorder) EventErrorf( return r.Eventf(object, metadata, EventSeverityError, reason, messageFmt, args...) } -// Eventf constructs an event from the given information -// and performs an HTTP POST to the webhook address. +// Eventf constructs an event from the given information and performs a HTTP POST to the webhook address. func (r *Recorder) Eventf( object corev1.ObjectReference, metadata map[string]string, diff --git a/runtime/go.mod b/runtime/go.mod index 2d0be251..a4fcc125 100644 --- a/runtime/go.mod +++ b/runtime/go.mod @@ -7,12 +7,17 @@ replace github.com/fluxcd/pkg/apis/meta => ../apis/meta require ( github.com/fluxcd/pkg/apis/meta v0.10.1 github.com/go-logr/logr v0.4.0 + github.com/google/go-cmp v0.5.5 github.com/hashicorp/go-retryablehttp v0.6.8 + github.com/onsi/gomega v1.13.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.11.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 go.uber.org/zap v1.17.0 + golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect + golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect + golang.org/x/tools v0.1.4 // indirect k8s.io/api v0.21.2 k8s.io/apimachinery v0.21.2 k8s.io/client-go v0.21.2 diff --git a/runtime/go.sum b/runtime/go.sum index 3d91c346..313ce074 100644 --- a/runtime/go.sum +++ b/runtime/go.sum @@ -386,6 +386,7 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= @@ -448,6 +449,7 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -478,8 +480,10 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -494,6 +498,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -535,10 +540,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= @@ -598,8 +606,9 @@ golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.4 h1:cVngSRcfgyZCzys3KYOpCFa+4dqX/Oub9tAq00ttGVs= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/runtime/leaderelection/doc.go b/runtime/leaderelection/doc.go new file mode 100644 index 00000000..903bb723 --- /dev/null +++ b/runtime/leaderelection/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Flux 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 leaderelection provides runtime configuration options for leader election, making it easier to consistently +// have the same configuration options and flags across GitOps Toolkit components. +package leaderelection diff --git a/runtime/leaderelection/leaderelection.go b/runtime/leaderelection/leaderelection.go index 05c0ebc3..92e6aa2f 100644 --- a/runtime/leaderelection/leaderelection.go +++ b/runtime/leaderelection/leaderelection.go @@ -30,35 +30,54 @@ const ( flagRetryPeriod = "leader-election-retry-period" ) -// Options contains the configuration options for the leader election. +// Options contains the runtime configuration for leader election. +// +// The struct can be used in the main.go file of your controller by binding it to the main flag set, and then utilizing +// the configured options later: +// +// func main() { +// var ( +// // other controller specific configuration variables +// leaderElectionOptions leaderelection.Options +// ) +// +// // Bind the options to the main flag set, and parse it +// leaderElectionOptions.BindFlags(flag.CommandLine) +// flag.Parse() +// +// // Use the values during the initialisation of the manager +// mgr, err := ctrl.NewManager(cfg, ctrl.Options{ +// ...other options +// LeaderElection: leaderElectionOptions.Enable, +// LeaderElectionReleaseOnCancel: leaderElectionOptions.ReleaseOnCancel, +// LeaseDuration: &leaderElectionOptions.LeaseDuration, +// RenewDeadline: &leaderElectionOptions.RenewDeadline, +// RetryPeriod: &leaderElectionOptions.RetryPeriod, +// }) +// } type Options struct { - // Enable determines whether or not to use leader election when - // starting the manager. + // Enable determines whether or not to use leader election when starting the manager. Enable bool - // ReleaseOnCancel defines if the leader should step down voluntarily - // when the Manager ends. This requires the binary to immediately end when the - // Manager is stopped, otherwise this setting is unsafe. Setting this significantly - // speeds up voluntary leader transitions as the new leader doesn't have to wait - // LeaseDuration time first. + // ReleaseOnCancel defines if the leader should step down voluntarily when the Manager ends. This requires the + // binary to immediately end when the Manager is stopped, otherwise this setting is unsafe. Setting this + // significantly speeds up voluntary leader transitions as the new leader doesn't have to wait LeaseDuration time + // first. ReleaseOnCancel bool - // LeaseDuration is the duration that non-leader candidates will - // wait to force acquire leadership. This is measured against time of - // last observed ack. Default is 35 seconds. + // LeaseDuration is the duration that non-leader candidates will wait to force acquire leadership. This is measured + // against time of last observed ack. Default is 35 seconds. LeaseDuration time.Duration - // RenewDeadline is the duration that the acting controlplane will retry - // refreshing leadership before giving up. Default is 30 seconds. + // RenewDeadline is the duration that the acting controlplane will retry refreshing leadership before giving up. + // Default is 30 seconds. RenewDeadline time.Duration - // RetryPeriod is the duration the LeaderElector clients should wait - // between tries of actions. Default is 5 seconds. + // RetryPeriod is the duration the LeaderElector clients should wait between tries of actions. Default is 5 seconds. RetryPeriod time.Duration } -// BindFlags will parse the given flagset for leader election option flags -// and set the Options accordingly. +// BindFlags will parse the given pflag.FlagSet for leader election option flags and set the Options accordingly. func (o *Options) BindFlags(fs *pflag.FlagSet) { fs.BoolVar(&o.Enable, flagEnable, false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.") diff --git a/runtime/logger/doc.go b/runtime/logger/doc.go new file mode 100644 index 00000000..187c7bc0 --- /dev/null +++ b/runtime/logger/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Flux 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 logger provides runtime configuration options for logging, making it easier to consistently have the same +// configuration options and flags across GitOps Toolkit components. +package logger diff --git a/runtime/logger/logger.go b/runtime/logger/logger.go index 77771b4f..f5e538cd 100644 --- a/runtime/logger/logger.go +++ b/runtime/logger/logger.go @@ -39,9 +39,8 @@ var levelStrings = map[string]zapcore.Level{ "error": zapcore.ErrorLevel, } -// These are for convenience when doing log.V(...) to log at a -// particular level. They correspond to the logr equivalents of the -// zap levels above. +// These are for convenience when doing log.V(...) to log at a particular level. They correspond to the logr +// equivalents of the zap levels above. const ( TraceLevel = 2 DebugLevel = 1 @@ -55,14 +54,30 @@ var stackLevelStrings = map[string]zapcore.Level{ "error": zapcore.PanicLevel, } -// Options contains the configuration options for the logger. +// Options contains the configuration options for the runtime logger. +// +// The struct can be used in the main.go file of your controller by binding it to the main flag set, and then utilizing +// the configured options later: +// +// func main() { +// var ( +// // other controller specific configuration variables +// loggerOptions logger.Options +// ) +// +// // Bind the options to the main flag set, and parse it +// loggerOptions.BindFlags(flag.CommandLine) +// flag.Parse() +// +// // Use the values during the initialisation of the logger +// ctrl.SetLogger(logger.NewLogger(logOptions)) +// } type Options struct { LogEncoding string LogLevel string } -// BindFlags will parse the given flagset for logger option flags and -// set the Options accordingly. +// BindFlags will parse the given pflag.FlagSet for logger option flags and set the Options accordingly. func (o *Options) BindFlags(fs *pflag.FlagSet) { fs.StringVar(&o.LogEncoding, flagLogEncoding, "json", "Log encoding format. Can be 'json' or 'console'.") @@ -70,8 +85,7 @@ func (o *Options) BindFlags(fs *pflag.FlagSet) { "Log verbosity level. Can be one of 'trace', 'debug', 'info', 'error'.") } -// NewLogger returns a logger configured with the given Options, -// and timestamps set to the ISO8601 format. +// NewLogger returns a logger configured with the given Options, and timestamps set to the ISO8601 format. func NewLogger(opts Options) logr.Logger { zapOpts := zap.Options{ EncoderConfigOptions: []zap.EncoderConfigOption{ diff --git a/runtime/metrics/doc.go b/runtime/metrics/doc.go new file mode 100644 index 00000000..19e45674 --- /dev/null +++ b/runtime/metrics/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 metrics contains a Recorder and helpers for recoding standard metrics for all GitOps Toolkit components. +package metrics diff --git a/runtime/metrics/recorder.go b/runtime/metrics/recorder.go index 3b64cc32..2aff29fd 100644 --- a/runtime/metrics/recorder.go +++ b/runtime/metrics/recorder.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Flux 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 metrics import ( @@ -13,12 +29,16 @@ const ( ConditionDeleted = "Deleted" ) +// Recorder is a struct for recording GitOps Toolkit metrics for a controller. +// +// Use NewRecorder to initialise it with properly configured metric names. type Recorder struct { conditionGauge *prometheus.GaugeVec suspendGauge *prometheus.GaugeVec durationHistogram *prometheus.HistogramVec } +// NewRecorder returns a new Recorder with all metric names configured confirm GitOps Toolkit standards. func NewRecorder() *Recorder { return &Recorder{ conditionGauge: prometheus.NewGaugeVec( @@ -46,10 +66,16 @@ func NewRecorder() *Recorder { } } +// Collectors returns a slice of Prometheus collectors, which can be used to register them in a metrics registry. func (r *Recorder) Collectors() []prometheus.Collector { - return []prometheus.Collector{r.conditionGauge, r.suspendGauge, r.durationHistogram} + return []prometheus.Collector{ + r.conditionGauge, + r.suspendGauge, + r.durationHistogram, + } } +// RecordCondition records the condition as given for the ref. func (r *Recorder) RecordCondition(ref corev1.ObjectReference, condition metav1.Condition, deleted bool) { for _, status := range []string{string(metav1.ConditionTrue), string(metav1.ConditionFalse), string(metav1.ConditionUnknown), ConditionDeleted} { var value float64 @@ -62,21 +88,20 @@ func (r *Recorder) RecordCondition(ref corev1.ObjectReference, condition metav1. value = 1 } } - r.conditionGauge.WithLabelValues(ref.Kind, ref.Name, ref.Namespace, condition.Type, status).Set(value) } } +// RecordSuspend records the suspend status as given for the ref. func (r *Recorder) RecordSuspend(ref corev1.ObjectReference, suspend bool) { var value float64 - if suspend { value = 1 } - r.suspendGauge.WithLabelValues(ref.Kind, ref.Name, ref.Namespace).Set(value) } +// RecordDuration records the duration since start for the given ref. func (r *Recorder) RecordDuration(ref corev1.ObjectReference, start time.Time) { r.durationHistogram.WithLabelValues(ref.Kind, ref.Name, ref.Namespace).Observe(time.Since(start).Seconds()) } diff --git a/runtime/metrics/recorder_test.go b/runtime/metrics/recorder_test.go index 2a9d8ef4..a96927f2 100644 --- a/runtime/metrics/recorder_test.go +++ b/runtime/metrics/recorder_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2021 The Flux 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 metrics import ( diff --git a/runtime/patch/doc.go b/runtime/patch/doc.go new file mode 100644 index 00000000..088381cf --- /dev/null +++ b/runtime/patch/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Flux 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 patch implements patch utilities to help with proper patching of objects while reducing the number of +// potential conflicts. +package patch diff --git a/runtime/patch/options.go b/runtime/patch/options.go new file mode 100644 index 00000000..2f63cb96 --- /dev/null +++ b/runtime/patch/options.go @@ -0,0 +1,73 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/d2faf482116114c4075da1390d905742e524ff89/util/patch/options.go, +and initially adapted to work with the `conditions` package and `metav1.Condition` types. +*/ + +package patch + +// Option is some configuration that modifies options for a patch request. +type Option interface { + // ApplyToHelper applies this configuration to the given Helper options. + ApplyToHelper(*HelperOptions) +} + +// HelperOptions contains options for patch options. +type HelperOptions struct { + // IncludeStatusObservedGeneration sets the status.observedGeneration field on the incoming object to match + // metadata.generation, only if there is a change. + IncludeStatusObservedGeneration bool + + // ForceOverwriteConditions allows the patch helper to overwrite conditions in case of conflicts. + // This option should only ever be set in controller managing the object being patched. + ForceOverwriteConditions bool + + // OwnedConditions defines condition types owned by the controller. + // In case of conflicts for the owned conditions, the patch helper will always use the value provided by the + // controller. + OwnedConditions []string +} + +// WithForceOverwriteConditions allows the patch helper to overwrite conditions in case of conflicts. +// This option should only ever be set in controller managing the object being patched. +type WithForceOverwriteConditions struct{} + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w WithForceOverwriteConditions) ApplyToHelper(in *HelperOptions) { + in.ForceOverwriteConditions = true +} + +// WithStatusObservedGeneration sets the status.observedGeneration field on the incoming object to match +// metadata.generation, only if there is a change. +type WithStatusObservedGeneration struct{} + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w WithStatusObservedGeneration) ApplyToHelper(in *HelperOptions) { + in.IncludeStatusObservedGeneration = true +} + +// WithOwnedConditions allows to define condition types owned by the controller. +// In case of conflicts for the owned conditions, the patch helper will always use the value provided by the controller. +type WithOwnedConditions struct { + Conditions []string +} + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w WithOwnedConditions) ApplyToHelper(in *HelperOptions) { + in.OwnedConditions = w.Conditions +} diff --git a/runtime/patch/patch.go b/runtime/patch/patch.go new file mode 100644 index 00000000..7ba47887 --- /dev/null +++ b/runtime/patch/patch.go @@ -0,0 +1,374 @@ +/* +Copyright 2017 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/d2faf482116114c4075da1390d905742e524ff89/util/patch/patch.go, +and initially adapted to work with the `conditions` package and `metav1.Condition` types. +*/ + +package patch + +import ( + "context" + "encoding/json" + "time" + + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + "github.com/fluxcd/pkg/runtime/conditions" +) + +// Helper is a utility for ensuring the proper patching of objects. +// +// The Helper MUST be initialised before a set of modifications within the scope of an envisioned patch are made +// to an object, so that the difference in state can be utilised to calculate a patch that can be used on a new revision +// of the resource in case of conflicts. +// +// A common pattern for reconcilers is to initialise a NewHelper at the beginning of their Reconcile method, after +// having fetched the latest revision for the resource from the API server, and then defer the call of Helper.Patch. +// This ensures any modifications made to the spec and the status (conditions) object of the resource are always +// persisted at the end of a reconcile run. +// +// func (r *FooReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { +// // Retrieve the object from the API server +// obj := &v1.Foo{} +// if err := r.Get(ctx, req.NamespacedName, obj); err != nil { +// return ctrl.Result{}, client.IgnoreNotFound(err) +// } +// +// // Initialise the patch helper +// patchHelper, err := patch.NewHelper(obj, r.Client) +// if err != nil { +// return ctrl.Result{}, err +// } +// +// // Always attempt to patch the object and status after each reconciliation +// defer func() { +// // Patch the object, ignoring conflicts on the conditions owned by this controller +// patchOpts := []patch.Option{ +// patch.WithOwnedConditions{ +// Conditions: []string{ +// meta.ReadyCondition, +// meta.ReconcilingCondition, +// meta.ProgressingReason, +// // any other "owned conditions" +// }, +// }, +// } +// +// // Determine if the resource is still being reconciled, or if it has stalled, and record this observation +// if retErr == nil && (result.IsZero() || !result.Requeue) { +// conditions.Delete(obj, meta.ReconcilingCondition) +// +// // We have now observed this generation +// patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) +// +// readyCondition := conditions.Get(obj, meta.ReadyCondition) +// switch readyCondition.Status { +// case metav1.ConditionFalse: +// // As we are no longer reconciling and the end-state is not ready, the reconciliation has stalled +// conditions.MarkTrue(obj, meta.StalledCondition, readyCondition.Reason, readyCondition.Message) +// case metav1.ConditionTrue: +// // As we are no longer reconciling and the end-state is ready, the reconciliation is no longer stalled +// conditions.Delete(obj, meta.StalledCondition) +// } +// } +// +// // Finally, patch the resource +// if err := patchHelper.Patch(ctx, obj, patchOpts...); err != nil { +// retErr = kerrors.NewAggregate([]error{retErr, err}) +// } +// }() +// +// // ...start with actual reconciliation logic +// } +// +// Using this pattern, one-off or scoped patches for a subset of a reconcile operation can be made by initialising a new +// Helper using NewHelper with the current state of the resource, making the modifications, and then directly applying +// the patch using Helper.Patch, for example: +// +// func (r *FooReconciler) subsetReconcile(ctx context.Context, obj *v1.Foo) (ctrl.Result, error) { +// patchHelper, err := patch.NewHelper(obj, r.Client) +// if err != nil { +// return ctrl.Result{}, err +// } +// +// // Set CustomField in status object of resource +// obj.Status.CustomField = "value" +// +// // Patch now only attempts to persist CustomField +// patchHelper.Patch(ctx, obj, nil) +// } +type Helper struct { + client client.Client + gvk schema.GroupVersionKind + beforeObject client.Object + before *unstructured.Unstructured + after *unstructured.Unstructured + changes map[string]bool + + isConditionsSetter bool +} + +// NewHelper returns an initialised Helper. +func NewHelper(obj client.Object, crClient client.Client) (*Helper, error) { + // Get the GroupVersionKind of the object, + // used to validate against later on. + gvk, err := apiutil.GVKForObject(obj, crClient.Scheme()) + if err != nil { + return nil, err + } + + // Convert the object to unstructured to compare against our before copy. + unstructuredObj, err := toUnstructured(obj) + if err != nil { + return nil, err + } + + // Check if the object satisfies the GitOps Toolkit API conditions contract. + _, canInterfaceConditions := obj.(conditions.Setter) + + return &Helper{ + client: crClient, + gvk: gvk, + before: unstructuredObj, + beforeObject: obj.DeepCopyObject().(client.Object), + isConditionsSetter: canInterfaceConditions, + }, nil +} + +// Patch will attempt to patch the given object, including its status. +func (h *Helper) Patch(ctx context.Context, obj client.Object, opts ...Option) error { + // Get the GroupVersionKind of the object that we want to patch. + gvk, err := apiutil.GVKForObject(obj, h.client.Scheme()) + if err != nil { + return err + } + if gvk != h.gvk { + return errors.Errorf("unmatched GroupVersionKind, expected %q got %q", h.gvk, gvk) + } + + // Calculate the options. + options := &HelperOptions{} + for _, opt := range opts { + opt.ApplyToHelper(options) + } + + // Convert the object to unstructured to compare against our before copy. + h.after, err = toUnstructured(obj) + if err != nil { + return err + } + + // Determine if the object has status. + if unstructuredHasStatus(h.after) { + if options.IncludeStatusObservedGeneration { + // Set status.observedGeneration if we're asked to do so. + if err := unstructured.SetNestedField(h.after.Object, h.after.GetGeneration(), "status", "observedGeneration"); err != nil { + return err + } + + // Restore the changes back to the original object. + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(h.after.Object, obj); err != nil { + return err + } + } + } + + // Calculate and store the top-level field changes (e.g. "metadata", "spec", "status") we have before/after. + h.changes, err = h.calculateChanges(obj) + if err != nil { + return err + } + + // Issue patches and return errors in an aggregate. + return kerrors.NewAggregate([]error{ + // Patch the conditions first. + // + // Given that we pass in metadata.resourceVersion to perform a 3-way-merge conflict resolution, + // patching conditions first avoids an extra loop if spec or status patch succeeds first + // given that causes the resourceVersion to mutate. + h.patchStatusConditions(ctx, obj, options.ForceOverwriteConditions, options.OwnedConditions), + + // Then proceed to patch the rest of the object. + h.patch(ctx, obj), + h.patchStatus(ctx, obj), + }) +} + +// patch issues a patch for metadata and spec. +func (h *Helper) patch(ctx context.Context, obj client.Object) error { + if !h.shouldPatch("metadata") && !h.shouldPatch("spec") { + return nil + } + beforeObject, afterObject, err := h.calculatePatch(obj, specPatch) + if err != nil { + return err + } + return h.client.Patch(ctx, afterObject, client.MergeFrom(beforeObject)) +} + +// patchStatus issues a patch if the status has changed. +func (h *Helper) patchStatus(ctx context.Context, obj client.Object) error { + if !h.shouldPatch("status") { + return nil + } + beforeObject, afterObject, err := h.calculatePatch(obj, statusPatch) + if err != nil { + return err + } + return h.client.Status().Patch(ctx, afterObject, client.MergeFrom(beforeObject)) +} + +// patchStatusConditions issues a patch if there are any changes to the conditions slice under the status subresource. +// This is a special case and it's handled separately given that we allow different controllers to act on conditions of +// the same object. +// +// This method has an internal backoff loop. When a conflict is detected, the method asks the Client for the a new +// version of the object we're trying to patch. +// +// Condition changes are then applied to the latest version of the object, and if there are no unresolvable conflicts, +// the patch is sent again. +func (h *Helper) patchStatusConditions(ctx context.Context, obj client.Object, forceOverwrite bool, ownedConditions []string) error { + // Nothing to do if the object isn't a condition patcher. + if !h.isConditionsSetter { + return nil + } + + // Make sure our before/after objects satisfy the proper interface before continuing. + // + // NOTE: The checks and error below are done so that we don't panic if any of the objects don't satisfy the + // interface any longer, although this shouldn't happen because we already check when creating the patcher. + before, ok := h.beforeObject.(conditions.Getter) + if !ok { + return errors.Errorf("object %s doesn't satisfy conditions.Getter, cannot patch", before.GetObjectKind()) + } + after, ok := obj.(conditions.Getter) + if !ok { + return errors.Errorf("object %s doesn't satisfy conditions.Getter, cannot patch", after.GetObjectKind()) + } + + // Store the diff from the before/after object, and return early if there are no changes. + diff := conditions.NewPatch( + before, + after, + ) + if diff.IsZero() { + return nil + } + + // Make a copy of the object and store the key used if we have conflicts. + key := client.ObjectKeyFromObject(after) + + // Define and start a backoff loop to handle conflicts + // between controllers working on the same object. + // + // This has been copied from https://github.com/kubernetes/kubernetes/blob/release-1.16/pkg/controller/controller_utils.go#L86-L88. + backoff := wait.Backoff{ + Steps: 5, + Duration: 100 * time.Millisecond, + Jitter: 1.0, + } + + // Start the backoff loop and return errors if any. + return wait.ExponentialBackoff(backoff, func() (bool, error) { + latest, ok := before.DeepCopyObject().(conditions.Setter) + if !ok { + return false, errors.Errorf("object %s doesn't satisfy conditions.Setter, cannot patch", latest.GetObjectKind()) + } + + // Get a new copy of the object. + if err := h.client.Get(ctx, key, latest); err != nil { + return false, err + } + + // Create the condition patch before merging conditions. + conditionsPatch := client.MergeFromWithOptions(latest.DeepCopyObject().(conditions.Setter), client.MergeFromWithOptimisticLock{}) + + // Set the condition patch previously created on the new object. + if err := diff.Apply(latest, conditions.WithForceOverwrite(forceOverwrite), conditions.WithOwnedConditions(ownedConditions...)); err != nil { + return false, err + } + + // Issue the patch. + err := h.client.Status().Patch(ctx, latest, conditionsPatch) + switch { + case apierrors.IsConflict(err): + // Requeue. + return false, nil + case err != nil: + return false, err + default: + return true, nil + } + }) +} + +// calculatePatch returns the before/after objects to be given in a controller-runtime patch, scoped down to the +// absolute necessary. +func (h *Helper) calculatePatch(afterObj client.Object, focus patchType) (client.Object, client.Object, error) { + // Get a shallow unsafe copy of the before/after object in unstructured form. + before := unsafeUnstructuredCopy(h.before, focus, h.isConditionsSetter) + after := unsafeUnstructuredCopy(h.after, focus, h.isConditionsSetter) + + // We've now applied all modifications to local unstructured objects, + // make copies of the original objects and convert them back. + beforeObj := h.beforeObject.DeepCopyObject().(client.Object) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(before.Object, beforeObj); err != nil { + return nil, nil, err + } + afterObj = afterObj.DeepCopyObject().(client.Object) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(after.Object, afterObj); err != nil { + return nil, nil, err + } + return beforeObj, afterObj, nil +} + +func (h *Helper) shouldPatch(in string) bool { + return h.changes[in] +} + +// calculate changes tries to build a patch from the before/after objects we have and store in a map which top-level +// fields (e.g. `metadata`, `spec`, `status`, etc.) have changed. +func (h *Helper) calculateChanges(after client.Object) (map[string]bool, error) { + // Calculate patch data. + patch := client.MergeFrom(h.beforeObject) + diff, err := patch.Data(after) + if err != nil { + return nil, errors.Wrapf(err, "failed to calculate patch data") + } + + // Unmarshal patch data into a local map. + patchDiff := map[string]interface{}{} + if err := json.Unmarshal(diff, &patchDiff); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal patch data into a map") + } + + // Return the map. + res := make(map[string]bool, len(patchDiff)) + for key := range patchDiff { + res[key] = true + } + return res, nil +} diff --git a/runtime/patch/utils.go b/runtime/patch/utils.go new file mode 100644 index 00000000..948fbe9e --- /dev/null +++ b/runtime/patch/utils.go @@ -0,0 +1,102 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/d2faf482116114c4075da1390d905742e524ff89/util/patch/utils.go, +and initially adapted to work with the `conditions` package and `metav1.Condition` types. +*/ + +package patch + +import ( + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +type patchType string + +func (p patchType) Key() string { + return strings.Split(string(p), ".")[0] +} + +const ( + specPatch patchType = "spec" + statusPatch patchType = "status" +) + +var ( + preserveUnstructuredKeys = map[string]bool{ + "kind": true, + "apiVersion": true, + "metadata": true, + } +) + +func unstructuredHasStatus(u *unstructured.Unstructured) bool { + _, ok := u.Object["status"] + return ok +} + +func toUnstructured(obj runtime.Object) (*unstructured.Unstructured, error) { + // If the incoming object is already unstructured, perform a deep copy first + // otherwise DefaultUnstructuredConverter ends up returning the inner map without + // making a copy. + if _, ok := obj.(runtime.Unstructured); ok { + obj = obj.DeepCopyObject() + } + rawMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: rawMap}, nil +} + +// unsafeUnstructuredCopy returns a shallow copy of the unstructured object given as input. +// It copies the common fields such as `kind`, `apiVersion`, `metadata` and the patchType specified. +// +// It's not safe to modify any of the keys in the returned unstructured object, the result should be treated as read-only. +func unsafeUnstructuredCopy(obj *unstructured.Unstructured, focus patchType, isConditionsSetter bool) *unstructured.Unstructured { + // Create the return focused-unstructured object with a preallocated map. + res := &unstructured.Unstructured{Object: make(map[string]interface{}, len(obj.Object))} + + // Ranges over the keys of the unstructured object, think of this as the very top level of an object + // when submitting a yaml to kubectl or a client. + // These would be keys like `apiVersion`, `kind`, `metadata`, `spec`, `status`, etc. + for key := range obj.Object { + value := obj.Object[key] + + // Perform a shallow copy only for the keys we're interested in, or the ones that should be always preserved. + if key == focus.Key() || preserveUnstructuredKeys[key] { + res.Object[key] = value + } + + // If we've determined that we're able to interface with conditions.Setter interface, + // when dealing with the status patch, remove the status.conditions sub-field from the object. + if isConditionsSetter && focus == statusPatch { + // NOTE: Removing status.conditions changes the incoming object! This is safe because the condition patch + // doesn't use the unstructured fields, and it runs before any other patch. + // + // If we want to be 100% safe, we could make a copy of the incoming object before modifying it, although + // copies have a high cpu and high memory usage, therefore we intentionally choose to avoid extra copies + // given that the ordering of operations and safety is handled internally by the patch helper. + unstructured.RemoveNestedField(res.Object, "status", "conditions") + } + } + + return res +} diff --git a/runtime/patch/utils_test.go b/runtime/patch/utils_test.go new file mode 100644 index 00000000..6450e5c9 --- /dev/null +++ b/runtime/patch/utils_test.go @@ -0,0 +1,231 @@ +/* +Copyright 2020 The Kubernetes Authors. +Copyright 2021 The Flux 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. + +This file is modified from the source at +https://github.com/kubernetes-sigs/cluster-api/tree/d2faf482116114c4075da1390d905742e524ff89/util/patch/utils_test.go, +and initially adapted to work with the `conditions` package and `metav1.Condition` types. +*/ + +package patch + +import ( + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestToUnstructured(t *testing.T) { + t.Run("with a typed object", func(t *testing.T) { + g := NewWithT(t) + // Test with a typed object. + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "configmap-1", + Namespace: "namespace-1", + }, + Data: map[string]string{ + "configmap": "true", + }, + } + newObj, err := toUnstructured(obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(newObj.GetName()).To(Equal(obj.Name)) + g.Expect(newObj.GetNamespace()).To(Equal(obj.Namespace)) + + // Change a spec field and validate that it stays the same in the incoming object. + g.Expect(unstructured.SetNestedField(newObj.Object, false, "data", "configmap")).To(Succeed()) + g.Expect(obj.Data["configmap"]).To(Equal("true")) + }) + + t.Run("with an unstructured object", func(t *testing.T) { + g := NewWithT(t) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "test.x.y.z/v1", + "metadata": map[string]interface{}{ + "name": "test-1", + "namespace": "namespace-1", + }, + "spec": map[string]interface{}{ + "paused": true, + }, + }, + } + + newObj, err := toUnstructured(obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(newObj.GetName()).To(Equal(obj.GetName())) + g.Expect(newObj.GetNamespace()).To(Equal(obj.GetNamespace())) + + // Validate that the maps point to different addresses. + g.Expect(obj.Object).ToNot(BeIdenticalTo(newObj.Object)) + + // Change a spec field and validate that it stays the same in the incoming object. + g.Expect(unstructured.SetNestedField(newObj.Object, false, "spec", "paused")).To(Succeed()) + pausedValue, _, err := unstructured.NestedBool(obj.Object, "spec", "paused") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(pausedValue).To(BeTrue()) + + // Change the name of the new object and make sure it doesn't change it the old one. + newObj.SetName("test-2") + g.Expect(obj.GetName()).To(Equal("test-1")) + }) +} + +func TestUnsafeFocusedUnstructured(t *testing.T) { + t.Run("focus=spec, should only return spec and common fields", func(t *testing.T) { + g := NewWithT(t) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "test.x.y.z/v1", + "kind": "TestCluster", + "metadata": map[string]interface{}{ + "name": "test-1", + "namespace": "namespace-1", + }, + "spec": map[string]interface{}{ + "paused": true, + }, + "status": map[string]interface{}{ + "infrastructureReady": true, + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + } + + newObj := unsafeUnstructuredCopy(obj, specPatch, true) + + // Validate that common fields are always preserved. + g.Expect(newObj.Object["apiVersion"]).To(Equal(obj.Object["apiVersion"])) + g.Expect(newObj.Object["kind"]).To(Equal(obj.Object["kind"])) + g.Expect(newObj.Object["metadata"]).To(Equal(obj.Object["metadata"])) + + // Validate that the spec has been preserved. + g.Expect(newObj.Object["spec"]).To(Equal(obj.Object["spec"])) + + // Validate that the status is nil, but preserved in the original object. + g.Expect(newObj.Object["status"]).To(BeNil()) + g.Expect(obj.Object["status"]).ToNot(BeNil()) + g.Expect(obj.Object["status"].(map[string]interface{})["conditions"]).ToNot(BeNil()) + }) + + t.Run("focus=status w/ condition-setter object, should only return status (without conditions) and common fields", func(t *testing.T) { + g := NewWithT(t) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "test.x.y.z/v1", + "kind": "TestCluster", + "metadata": map[string]interface{}{ + "name": "test-1", + "namespace": "namespace-1", + }, + "spec": map[string]interface{}{ + "paused": true, + }, + "status": map[string]interface{}{ + "infrastructureReady": true, + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + } + + newObj := unsafeUnstructuredCopy(obj, statusPatch, true) + + // Validate that common fields are always preserved. + g.Expect(newObj.Object["apiVersion"]).To(Equal(obj.Object["apiVersion"])) + g.Expect(newObj.Object["kind"]).To(Equal(obj.Object["kind"])) + g.Expect(newObj.Object["metadata"]).To(Equal(obj.Object["metadata"])) + + // Validate that spec is nil in the new object, but still exists in the old copy. + g.Expect(newObj.Object["spec"]).To(BeNil()) + g.Expect(obj.Object["spec"]).To(Equal(map[string]interface{}{ + "paused": true, + })) + + // Validate that the status has been copied, without conditions. + g.Expect(newObj.Object["status"]).To(HaveLen(1)) + g.Expect(newObj.Object["status"].(map[string]interface{})["infrastructureReady"]).To(Equal(true)) + g.Expect(newObj.Object["status"].(map[string]interface{})["conditions"]).To(BeNil()) + + // When working with conditions, the inner map is going to be removed from the original object. + g.Expect(obj.Object["status"].(map[string]interface{})["conditions"]).To(BeNil()) + }) + + t.Run("focus=status w/o condition-setter object, should only return status and common fields", func(t *testing.T) { + g := NewWithT(t) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "test.x.y.z/v1", + "kind": "TestCluster", + "metadata": map[string]interface{}{ + "name": "test-1", + "namespace": "namespace-1", + }, + "spec": map[string]interface{}{ + "paused": true, + "other": "field", + }, + "status": map[string]interface{}{ + "infrastructureReady": true, + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + } + + newObj := unsafeUnstructuredCopy(obj, statusPatch, false) + + // Validate that spec is nil in the new object, but still exists in the old copy. + g.Expect(newObj.Object["spec"]).To(BeNil()) + g.Expect(obj.Object["spec"]).To(Equal(map[string]interface{}{ + "paused": true, + "other": "field", + })) + + // Validate that common fields are always preserved. + g.Expect(newObj.Object["apiVersion"]).To(Equal(obj.Object["apiVersion"])) + g.Expect(newObj.Object["kind"]).To(Equal(obj.Object["kind"])) + g.Expect(newObj.Object["metadata"]).To(Equal(obj.Object["metadata"])) + + // Validate that the status has been copied, without conditions. + g.Expect(newObj.Object["status"]).To(HaveLen(2)) + g.Expect(newObj.Object["status"]).To(Equal(obj.Object["status"])) + + // Make sure that we didn't modify the incoming object if this object isn't a condition setter. + g.Expect(obj.Object["status"].(map[string]interface{})["conditions"]).ToNot(BeNil()) + }) +} diff --git a/runtime/pprof/doc.go b/runtime/pprof/doc.go new file mode 100644 index 00000000..2284875b --- /dev/null +++ b/runtime/pprof/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 pprof contains a helper to register pprof endpoints on a controller-runtime manager. +package pprof diff --git a/runtime/pprof/pprof.go b/runtime/pprof/pprof.go index 88560228..bf6ffaf4 100644 --- a/runtime/pprof/pprof.go +++ b/runtime/pprof/pprof.go @@ -19,23 +19,50 @@ package pprof import ( "net/http" "net/http/pprof" + "runtime" "github.com/go-logr/logr" ctrl "sigs.k8s.io/controller-runtime" ) +// HTTPPrefixPProf is the prefix appended to all endpoints. +const HTTPPrefixPProf = "/debug/pprof" + var endpoints = map[string]http.Handler{ - "/debug/pprof/": http.HandlerFunc(pprof.Index), - "/debug/pprof/cmdline": http.HandlerFunc(pprof.Cmdline), - "/debug/pprof/profile": http.HandlerFunc(pprof.Profile), - "/debug/pprof/symbol": http.HandlerFunc(pprof.Symbol), - "/debug/pprof/trace": http.HandlerFunc(pprof.Trace), + HTTPPrefixPProf + "/": http.HandlerFunc(pprof.Index), + HTTPPrefixPProf + "/cmdline": http.HandlerFunc(pprof.Cmdline), + HTTPPrefixPProf + "/profile": http.HandlerFunc(pprof.Profile), + HTTPPrefixPProf + "/symbol": http.HandlerFunc(pprof.Symbol), + HTTPPrefixPProf + "/trace": http.HandlerFunc(pprof.Trace), + HTTPPrefixPProf + "/heap": pprof.Handler("heap"), + HTTPPrefixPProf + "/goroutine": pprof.Handler("goroutine"), + HTTPPrefixPProf + "/threadcreate": pprof.Handler("threadcreate"), + HTTPPrefixPProf + "/block": pprof.Handler("block"), + HTTPPrefixPProf + "/mutex": pprof.Handler("mutex"), } -func SetupHandlers(mgr ctrl.Manager, setupLog logr.Logger) { +// SetupHandlers registers the pprof endpoints on the metrics server of the given mgr. +// +// The func can be used in the main.go file of your controller, after initialisation of the manager: +// +// func main() { +// mgr, err := ctrl.NewManager(cfg, ctrl.Options{}) +// if err != nil { +// log.Error(err, "unable to start manager") +// os.Exit(1) +// } +// pprof.SetupHandlers(mgr, log) +// } +func SetupHandlers(mgr ctrl.Manager, log logr.Logger) { + // Only set the fraction if there is no existing setting + if runtime.SetMutexProfileFraction(-1) == 0 { + // Default to report 1 out of 5 mutex events, on average + runtime.SetMutexProfileFraction(5) + } + for p, h := range endpoints { if err := mgr.AddMetricsExtraHandler(p, h); err != nil { - setupLog.Error(err, "unable to add pprof handler") + log.Error(err, "unable to add pprof handler") } } } diff --git a/runtime/predicates/doc.go b/runtime/predicates/doc.go new file mode 100644 index 00000000..7b5122a1 --- /dev/null +++ b/runtime/predicates/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 predicates provides generic controller-runtime predicates for GitOps Toolkit components to filter events. +package predicates diff --git a/runtime/predicates/reconcile_at_changed.go b/runtime/predicates/reconcile_at_changed.go index 2d4658a4..4fbd6659 100644 --- a/runtime/predicates/reconcile_at_changed.go +++ b/runtime/predicates/reconcile_at_changed.go @@ -16,7 +16,9 @@ limitations under the License. package predicates -// Deprecated, use ReconcileRequestedPredicate instead. +// ReconcilateAtChangedPredicate detects meta.ReconcileAtAnnotation changes. +// +// DEPRECATED: use ReconcileRequestedPredicate instead. type ReconcilateAtChangedPredicate struct { ReconcileRequestedPredicate } diff --git a/runtime/predicates/reconcile_requested.go b/runtime/predicates/reconcile_requested.go index f7ac46b3..c8921ec9 100644 --- a/runtime/predicates/reconcile_requested.go +++ b/runtime/predicates/reconcile_requested.go @@ -23,15 +23,12 @@ import ( metav1 "github.com/fluxcd/pkg/apis/meta" ) -// ReconcileRequestedPredicate implements an update predicate -// function for meta.ReconcileAtAnnotation changes. +// ReconcileRequestedPredicate implements an update predicate function for meta.ReconcileRequestAnnotation changes. +// This predicate will skip update events that have no meta.ReconcileRequestAnnotation change. // -// This predicate will skip update events that have no -// meta.ReconcileAtAnnotation change. -// It is intended to be used in conjunction with the -// predicate.GenerationChangedPredicate, as in the following example: +// It is intended to be used in conjunction with the predicate.GenerationChangedPredicate, as in the following example: // -// Controller.Watch( +// Controller.Watch( // &source.Kind{Type: v1.MyCustomKind}, // &handler.EnqueueRequestForObject{}, // predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{})) @@ -39,8 +36,7 @@ type ReconcileRequestedPredicate struct { predicate.Funcs } -// Update implements the default UpdateEvent filter for validating -// meta.ReconcileAtAnnotation changes. +// Update implements the default UpdateEvent filter for validating meta.ReconcileRequestAnnotation changes. func (ReconcileRequestedPredicate) Update(e event.UpdateEvent) bool { if e.ObjectOld == nil || e.ObjectNew == nil { return false diff --git a/runtime/probes/doc.go b/runtime/probes/doc.go new file mode 100644 index 00000000..27e5dc73 --- /dev/null +++ b/runtime/probes/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Flux 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 probes contains a helper to configure sensible default health and ready probes on a controller-runtime +// manager. +package probes diff --git a/runtime/probes/probes.go b/runtime/probes/probes.go index 97c1d38e..7da3c984 100644 --- a/runtime/probes/probes.go +++ b/runtime/probes/probes.go @@ -24,14 +24,26 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" ) -func SetupChecks(mgr ctrl.Manager, setupLog logr.Logger) { +// SetupChecks configures simple default ready and health probes on the given mgr. +// +// The func can be used in the main.go file of your controller, after initialisation of the manager: +// +// func main() { +// mgr, err := ctrl.NewManager(cfg, ctrl.Options{}) +// if err != nil { +// log.Error(err, "unable to start manager") +// os.Exit(1) +// } +// probes.SetupChecks(mgr, log) +// } +func SetupChecks(mgr ctrl.Manager, log logr.Logger) { if err := mgr.AddReadyzCheck("ping", healthz.Ping); err != nil { - setupLog.Error(err, "unable to create ready check") + log.Error(err, "unable to create ready check") os.Exit(1) } if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { - setupLog.Error(err, "unable to create health check") + log.Error(err, "unable to create health check") os.Exit(1) } } diff --git a/runtime/testenv/doc.go b/runtime/testenv/doc.go new file mode 100644 index 00000000..11f2a581 --- /dev/null +++ b/runtime/testenv/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2021 The Flux 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 testenv contains helpers to create and work with an encapsulated local Kubernetes test environment. +// +// For general advice around testing, see: https://cluster-api.sigs.k8s.io/developer/testing.html +// +// For more information about the encapsulated local Kubernetes test environment, see: +// https://book.kubebuilder.io/reference/envtest.html +package testenv diff --git a/runtime/testenv/testenv.go b/runtime/testenv/testenv.go index c4b969e8..510d9c3f 100644 --- a/runtime/testenv/testenv.go +++ b/runtime/testenv/testenv.go @@ -94,16 +94,14 @@ func (o *options) withDefaults() { type Option func(*options) // WithScheme configures the runtime.Scheme for the Environment. -// If no scheme is configured, the Environment defaults to the global -// runtime.Scheme. +// If no scheme is configured, the Environment defaults to the global runtime.Scheme. func WithScheme(scheme *runtime.Scheme) Option { return func(o *options) { o.scheme = scheme } } -// WithCRDPath configures the paths the envtest.Environment should look -// at for Custom Resource Definitions. +// WithCRDPath configures the paths the envtest.Environment should look at for Custom Resource Definitions. func WithCRDPath(path ...string) Option { return func(o *options) { o.crdDirectoryPaths = append(o.crdDirectoryPaths, path...) @@ -112,9 +110,8 @@ func WithCRDPath(path ...string) Option { // New creates a new environment spinning up a local api-server. // -// NOTE: This function should be called only once for each package you're -// running tests within, usually the environment is initialized in a -// suite_test.go or _test.go file within a `TestMain` function. +// NOTE: This function should be called only once for each package you are running tests within, usually the environment +// is initialised in a suite_test.go or _test.go file within a `TestMain` function. func New(o ...Option) *Environment { opts := options{} for _, apply := range o { diff --git a/runtime/transform/doc.go b/runtime/transform/doc.go new file mode 100644 index 00000000..a1dba11d --- /dev/null +++ b/runtime/transform/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Flux 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 transform contains utilities for transforming types. +package transform diff --git a/runtime/transform/transform.go b/runtime/transform/transform.go index 67251860..cd26441b 100644 --- a/runtime/transform/transform.go +++ b/runtime/transform/transform.go @@ -18,9 +18,8 @@ package transform // MergeMaps merges map b into given map a and returns the result. // It allows overwrites of map values with flat values, and vice versa. -// This is copied from https://github.com/helm/helm/blob/v3.3.0/pkg/cli/values/options.go#L88, -// as the public chartutil.CoalesceTables function does not allow -// overwriting maps with flat values. +// +// Originally copied over from https://github.com/helm/helm/blob/v3.3.0/pkg/cli/values/options.go#L88. func MergeMaps(a, b map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(a)) for k, v := range a {