diff --git a/.golangci.yml b/.golangci.yml index 80d2f5be2d35..623add965a42 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -275,6 +275,19 @@ linters: - linters: - errcheck text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked + # v1beta1 deprecated util packages + - linters: + - staticcheck + text: 'SA1019: .* is deprecated' + path: util/deprecated/v1beta1/.*\.go$ + - linters: + - staticcheck + text: "QF1001: could apply De Morgan's law" + path: util/deprecated/v1beta1/.*\.go$ + - linters: + - importas + text: 'imported as ".*" but must be ".*" according to config' + path: util/deprecated/v1beta1/.*\.go$ # Exclude some packages or code to require comments, for example test code, or fake clients. - linters: - revive @@ -365,9 +378,17 @@ linters: - linters: - govet text: non-constant format string in call to sigs\.k8s\.io\/cluster-api\/util\/conditions\. + - linters: + - govet + text: non-constant format string in call to sigs\.k8s\.io\/cluster-api\/util\/deprecated\/v1beta1\/conditions\. - linters: - goconst path: (.+)_test\.go + # It's clearer to see that a field gets accessed or func gets called on the embedded objects + - linters: + - staticcheck + path: (.+)\.go$ + text: 'QF1008: could remove embedded field' issues: max-issues-per-linter: 0 max-same-issues: 0 diff --git a/Makefile b/Makefile index a65d8051a937..74afaa9d6820 100644 --- a/Makefile +++ b/Makefile @@ -324,6 +324,10 @@ generate-manifests-core: $(CONTROLLER_GEN) $(KUSTOMIZE) ## Generate manifests e. paths=./controllers/crdmigrator/test/t4/... \ crd:crdVersions=v1 \ output:crd:dir=./controllers/crdmigrator/test/t4/crd + $(CONTROLLER_GEN) \ + paths=./util/deprecated/v1beta1/test/builder/... \ + crd:crdVersions=v1 \ + output:crd:dir=./util/deprecated/v1beta1/test/builder/crd .PHONY: generate-manifests-kubeadm-bootstrap generate-manifests-kubeadm-bootstrap: $(CONTROLLER_GEN) ## Generate manifests e.g. CRD, RBAC etc. for kubeadm bootstrap @@ -400,6 +404,7 @@ generate-go-deepcopy-core: $(CONTROLLER_GEN) ## Generate deepcopy go code for co paths=./internal/runtime/test/... \ paths=./cmd/clusterctl/... \ paths=./util/test/builder/... \ + paths=./util/deprecated/v1beta1/test/builder/... \ paths=./controllers/crdmigrator/test/... .PHONY: generate-go-deepcopy-kubeadm-bootstrap diff --git a/internal/test/envtest/environment.go b/internal/test/envtest/environment.go index d90e1bec1bb0..0b99a6593400 100644 --- a/internal/test/envtest/environment.go +++ b/internal/test/envtest/environment.go @@ -32,10 +32,13 @@ import ( "github.com/onsi/ginkgo/v2" "github.com/pkg/errors" admissionv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" kerrors "k8s.io/apimachinery/pkg/util/errors" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" @@ -104,29 +107,34 @@ func init() { // Add logger for ginkgo. klog.SetOutput(ginkgo.GinkgoWriter) - // Calculate the scheme. - utilruntime.Must(apiextensionsv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(admissionv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(clusterv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(bootstrapv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(expv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(controlplanev1.AddToScheme(scheme.Scheme)) - utilruntime.Must(admissionv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(runtimev1.AddToScheme(scheme.Scheme)) - utilruntime.Must(ipamv1.AddToScheme(scheme.Scheme)) - utilruntime.Must(builder.AddTransitionV1Beta2ToScheme(scheme.Scheme)) - utilruntime.Must(addonsv1.AddToScheme(scheme.Scheme)) + // Calculate the global scheme used by fakeclients. + registerSchemes(scheme.Scheme) +} + +func registerSchemes(s *runtime.Scheme) { + utilruntime.Must(admissionv1.AddToScheme(s)) + utilruntime.Must(apiextensionsv1.AddToScheme(s)) + + utilruntime.Must(addonsv1.AddToScheme(s)) + utilruntime.Must(bootstrapv1.AddToScheme(s)) + utilruntime.Must(clusterv1.AddToScheme(s)) + utilruntime.Must(controlplanev1.AddToScheme(s)) + utilruntime.Must(expv1.AddToScheme(s)) + utilruntime.Must(ipamv1.AddToScheme(s)) + utilruntime.Must(runtimev1.AddToScheme(s)) } // RunInput is the input for Run. type RunInput struct { - M *testing.M - ManagerUncachedObjs []client.Object - ManagerCacheOptions cache.Options - SetupIndexes func(ctx context.Context, mgr ctrl.Manager) - SetupReconcilers func(ctx context.Context, mgr ctrl.Manager) - SetupEnv func(e *Environment) - MinK8sVersion string + M *testing.M + ManagerUncachedObjs []client.Object + ManagerCacheOptions cache.Options + SetupIndexes func(ctx context.Context, mgr ctrl.Manager) + SetupReconcilers func(ctx context.Context, mgr ctrl.Manager) + SetupEnv func(e *Environment) + MinK8sVersion string + AdditionalSchemeBuilder runtime.SchemeBuilder + AdditionalCRDDirectoryPaths []string } // Run executes the tests of the given testing.M in a test environment. @@ -147,8 +155,20 @@ func Run(ctx context.Context, input RunInput) int { return input.M.Run() } + // Calculate the scheme. + scheme := runtime.NewScheme() + registerSchemes(scheme) + // Register additional schemes from k8s APIs. + utilruntime.Must(appsv1.AddToScheme(scheme)) + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(rbacv1.AddToScheme(scheme)) + // Register additionally passed schemes. + if input.AdditionalSchemeBuilder != nil { + utilruntime.Must(input.AdditionalSchemeBuilder.AddToScheme(scheme)) + } + // Bootstrapping test environment - env := newEnvironment(input.ManagerCacheOptions, input.ManagerUncachedObjs...) + env := newEnvironment(scheme, input.AdditionalCRDDirectoryPaths, input.ManagerCacheOptions, input.ManagerUncachedObjs...) ctx, cancel := context.WithCancelCause(ctx) env.cancelManager = cancel @@ -233,20 +253,24 @@ type Environment struct { // // 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 file within a `BeforeSuite` ginkgo block. -func newEnvironment(managerCacheOptions cache.Options, uncachedObjs ...client.Object) *Environment { +func newEnvironment(scheme *runtime.Scheme, additionalCRDDirectoryPaths []string, managerCacheOptions cache.Options, uncachedObjs ...client.Object) *Environment { // Get the root of the current file to use in CRD paths. _, filename, _, _ := goruntime.Caller(0) //nolint:dogsled root := path.Join(path.Dir(filename), "..", "..", "..") + crdDirectoryPaths := []string{ + filepath.Join(root, "config", "crd", "bases"), + filepath.Join(root, "controlplane", "kubeadm", "config", "crd", "bases"), + filepath.Join(root, "bootstrap", "kubeadm", "config", "crd", "bases"), + } + for _, path := range additionalCRDDirectoryPaths { + crdDirectoryPaths = append(crdDirectoryPaths, filepath.Join(root, path)) + } + // Create the test environment. env := &envtest.Environment{ ErrorIfCRDPathMissing: true, - CRDDirectoryPaths: []string{ - filepath.Join(root, "config", "crd", "bases"), - filepath.Join(root, "controlplane", "kubeadm", "config", "crd", "bases"), - filepath.Join(root, "bootstrap", "kubeadm", "config", "crd", "bases"), - filepath.Join(root, "util", "test", "builder", "crd"), - }, + CRDDirectoryPaths: crdDirectoryPaths, CRDs: []*apiextensionsv1.CustomResourceDefinition{ builder.GenericBootstrapConfigCRD.DeepCopy(), builder.GenericBootstrapConfigTemplateCRD.DeepCopy(), @@ -296,7 +320,7 @@ func newEnvironment(managerCacheOptions cache.Options, uncachedObjs ...client.Ob Controller: config.Controller{ UsePriorityQueue: ptr.To[bool](feature.Gates.Enabled(feature.PriorityQueue)), }, - Scheme: scheme.Scheme, + Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: "0", }, diff --git a/util/deprecated/v1beta1/conditions/doc.go b/util/deprecated/v1beta1/conditions/doc.go new file mode 100644 index 000000000000..9b603b7904ea --- /dev/null +++ b/util/deprecated/v1beta1/conditions/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package conditions implements condition utilities. +// +// Deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details. +package conditions diff --git a/util/deprecated/v1beta1/conditions/getter.go b/util/deprecated/v1beta1/conditions/getter.go new file mode 100644 index 000000000000..47f07088b757 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/getter.go @@ -0,0 +1,273 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// Getter interface defines methods that a Cluster API object should implement in order to +// use the conditions package for getting conditions. +type Getter interface { + client.Object + + // GetConditions returns the list of conditions for a cluster API object. + GetConditions() clusterv1.Conditions +} + +// Get returns the condition with the given type, if the condition does not exist, +// it returns nil. +func Get(from Getter, t clusterv1.ConditionType) *clusterv1.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 clusterv1.ConditionType) bool { + return Get(from, t) != nil +} + +// IsTrue is true if the condition with the given type is True, otherwise it returns false +// if the condition is not True or if the condition does not exist (is nil). +func IsTrue(from Getter, t clusterv1.ConditionType) bool { + if c := Get(from, t); c != nil { + return c.Status == corev1.ConditionTrue + } + return false +} + +// IsFalse is true if the condition with the given type is False, otherwise it returns false +// if the condition is not False or if the condition does not exist (is nil). +func IsFalse(from Getter, t clusterv1.ConditionType) bool { + if c := Get(from, t); c != nil { + return c.Status == corev1.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 clusterv1.ConditionType) bool { + if c := Get(from, t); c != nil { + return c.Status == corev1.ConditionUnknown + } + return true +} + +// GetReason returns a nil safe string of Reason for the condition with the given type. +func GetReason(from Getter, t clusterv1.ConditionType) string { + if c := Get(from, t); c != nil { + return c.Reason + } + return "" +} + +// GetMessage returns a nil safe string of Message. +func GetMessage(from Getter, t clusterv1.ConditionType) string { + if c := Get(from, t); c != nil { + return c.Message + } + return "" +} + +// GetSeverity returns the condition Severity or nil if the condition +// does not exist (is nil). +func GetSeverity(from Getter, t clusterv1.ConditionType) *clusterv1.ConditionSeverity { + if c := Get(from, t); c != nil { + return &c.Severity + } + return nil +} + +// GetLastTransitionTime returns the condition Severity or nil if the condition +// does not exist (is nil). +func GetLastTransitionTime(from Getter, t clusterv1.ConditionType) *metav1.Time { + if c := Get(from, t); c != nil { + return &c.LastTransitionTime + } + return nil +} + +// summary returns a Ready 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. +// NOTE: The resulting Ready condition will have positive polarity; the conditions we are starting from might have positive or negative polarity. +func summary(from Getter, options ...MergeOption) *clusterv1.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 Ready, + // 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 == clusterv1.ReadyCondition { + continue + } + + if mergeOpt.conditionTypes != nil { + found := false + for _, t := range mergeOpt.conditionTypes { + if c.Type == t { + found = true + break + } + } + if !found { + continue + } + } + + // Keep track of the polarity of the condition we are starting from. + polarity := PositivePolarity + for _, t := range mergeOpt.negativeConditionTypes { + if c.Type == t { + polarity = NegativePolarity + break + } + } + + conditionsInScope = append(conditionsInScope, localizedCondition{ + Condition: &c, + Polarity: polarity, + 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 _, t := range mergeOpt.addStepCounterIfOnlyConditionTypes { + if c.Type == t { + 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, clusterv1.ReadyCondition, mergeOpt) +} + +// mirrorOptions allows to set options for the mirror operation. +type mirrorOptions struct { + fallbackTo *bool + fallbackReason string + fallbackSeverity clusterv1.ConditionSeverity + 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 exist; +// in case the fallbackValue is false, given values for reason, severity and message will be used. +func WithFallbackValue(fallbackValue bool, reason string, severity clusterv1.ConditionSeverity, message string) MirrorOptions { + return func(c *mirrorOptions) { + c.fallbackTo = &fallbackValue + c.fallbackReason = reason + c.fallbackSeverity = severity + c.fallbackMessage = message + } +} + +// mirror mirrors the Ready condition from a dependent object into the target condition; +// if the Ready condition does not exist in the source object, no target conditions is generated. +// NOTE: Considering that we are mirroring Ready conditions with positive polarity, also the resulting condition will have positive polarity. +func mirror(from Getter, targetCondition clusterv1.ConditionType, options ...MirrorOptions) *clusterv1.Condition { + mirrorOpt := &mirrorOptions{} + for _, o := range options { + o(mirrorOpt) + } + + condition := Get(from, clusterv1.ReadyCondition) + + if mirrorOpt.fallbackTo != nil && condition == nil { + switch *mirrorOpt.fallbackTo { + case true: + condition = TrueCondition(targetCondition) + case false: + condition = FalseCondition(targetCondition, mirrorOpt.fallbackReason, mirrorOpt.fallbackSeverity, "%s", mirrorOpt.fallbackMessage) + } + } + + if condition != nil { + condition.Type = targetCondition + } + + return condition +} + +// Aggregates all the Ready condition from a list of dependent objects into the target object; +// if the Ready condition does not exist in one of the source object, the object is excluded from +// the aggregation; if none of the source object have ready condition, no target conditions is generated. +// NOTE: Considering that we are aggregating Ready conditions with positive polarity, also the resulting condition will have positive polarity. +func aggregate(from []Getter, targetCondition clusterv1.ConditionType, options ...MergeOption) *clusterv1.Condition { + conditionsInScope := make([]localizedCondition, 0, len(from)) + for i := range from { + condition := Get(from[i], clusterv1.ReadyCondition) + + conditionsInScope = append(conditionsInScope, localizedCondition{ + Condition: condition, + Polarity: PositivePolarity, + Getter: from[i], + }) + } + + mergeOpt := &mergeOptions{ + addStepCounter: true, + stepCounter: len(from), + } + for _, o := range options { + o(mergeOpt) + } + return merge(conditionsInScope, targetCondition, mergeOpt) +} diff --git a/util/deprecated/v1beta1/conditions/getter_test.go b/util/deprecated/v1beta1/conditions/getter_test.go new file mode 100644 index 000000000000..7b3e0e807d1c --- /dev/null +++ b/util/deprecated/v1beta1/conditions/getter_test.go @@ -0,0 +1,350 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/sets" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +var ( + nil1 *clusterv1.Condition + true1 = TrueCondition("true1") + unknown1 = UnknownCondition("unknown1", "reason unknown1", "message unknown1") + falseInfo1 = FalseCondition("falseInfo1", "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1") + falseWarning1 = FalseCondition("falseWarning1", "reason falseWarning1", clusterv1.ConditionSeverityWarning, "message falseWarning1") + falseError1 = FalseCondition("falseError1", "reason falseError1", clusterv1.ConditionSeverityError, "message falseError1") + + negativePolarityConditions = sets.New("false1-negative-polarity", "unknown1-negative-polarity", "trueInfo1-negative-polarity", "trueWarning1-negative-polarity", "trueError1-negative-polarity") + false1WithNegativePolarity = FalseConditionWithNegativePolarity("false1-negative-polarity") + unknown1WithNegativePolarity = UnknownCondition("unknown1-negative-polarity", "reason unknown1-negative-polarity", "message unknown1-negative-polarity") + trueInfo1WithNegativePolarity = TrueConditionWithNegativePolarity("trueInfo1-negative-polarity", "reason trueInfo1-negative-polarity", clusterv1.ConditionSeverityInfo, "message trueInfo1-negative-polarity") + trueWarning1WithNegativePolarity = TrueConditionWithNegativePolarity("trueWarning1-negative-polarity", "reason trueWarning1-negative-polarity", clusterv1.ConditionSeverityWarning, "message trueWarning1-negative-polarity") + trueError1WithNegativePolarity = TrueConditionWithNegativePolarity("trueError1-negative-polarity", "reason trueError1-negative-polarity", clusterv1.ConditionSeverityError, "message trueError1-negative-polarity") +) + +func TestGetAndHas(t *testing.T) { + g := NewWithT(t) + + cluster := &clusterv1.Cluster{} + + g.Expect(Has(cluster, "conditionBaz")).To(BeFalse()) + g.Expect(Get(cluster, "conditionBaz")).To(BeNil()) + + cluster.SetConditions(conditionList(TrueCondition("conditionBaz"))) + + g.Expect(Has(cluster, "conditionBaz")).To(BeTrue()) + g.Expect(Get(cluster, "conditionBaz")).To(HaveSameStateOf(TrueCondition("conditionBaz"))) +} + +func TestIsMethods(t *testing.T) { + g := NewWithT(t) + + obj := getterWithConditions(nil1, true1, unknown1, falseInfo1, falseWarning1, falseError1, false1WithNegativePolarity, unknown1WithNegativePolarity, trueInfo1WithNegativePolarity, trueWarning1WithNegativePolarity, trueError1WithNegativePolarity) + + // test isTrue + g.Expect(IsTrue(obj, "nil1")).To(BeFalse()) + g.Expect(IsTrue(obj, "true1")).To(BeTrue()) + g.Expect(IsTrue(obj, "falseInfo1")).To(BeFalse()) + g.Expect(IsTrue(obj, "unknown1")).To(BeFalse()) + g.Expect(IsTrue(obj, "false1-negative-polarity")).To(BeFalse()) + g.Expect(IsTrue(obj, "trueInfo1-negative-polarity")).To(BeTrue()) + g.Expect(IsTrue(obj, "unknown1-negative-polarity")).To(BeFalse()) + + // test isFalse + g.Expect(IsFalse(obj, "nil1")).To(BeFalse()) + g.Expect(IsFalse(obj, "true1")).To(BeFalse()) + g.Expect(IsFalse(obj, "falseInfo1")).To(BeTrue()) + g.Expect(IsFalse(obj, "unknown1")).To(BeFalse()) + g.Expect(IsFalse(obj, "false1-negative-polarity")).To(BeTrue()) + g.Expect(IsFalse(obj, "trueInfo1-negative-polarity")).To(BeFalse()) + g.Expect(IsFalse(obj, "unknown1-negative-polarity")).To(BeFalse()) + + // test isUnknown + g.Expect(IsUnknown(obj, "nil1")).To(BeTrue()) + g.Expect(IsUnknown(obj, "true1")).To(BeFalse()) + g.Expect(IsUnknown(obj, "falseInfo1")).To(BeFalse()) + g.Expect(IsUnknown(obj, "unknown1")).To(BeTrue()) + g.Expect(IsUnknown(obj, "false1-negative-polarity")).To(BeFalse()) + g.Expect(IsUnknown(obj, "trueInfo1-negative-polarity")).To(BeFalse()) + g.Expect(IsUnknown(obj, "unknown1-negative-polarity")).To(BeTrue()) + + // test GetReason + g.Expect(GetReason(obj, "nil1")).To(Equal("")) + g.Expect(GetReason(obj, "falseInfo1")).To(Equal("reason falseInfo1")) + g.Expect(GetReason(obj, "trueInfo1-negative-polarity")).To(Equal("reason trueInfo1-negative-polarity")) + + // test GetMessage + g.Expect(GetMessage(obj, "nil1")).To(Equal("")) + g.Expect(GetMessage(obj, "falseInfo1")).To(Equal("message falseInfo1")) + g.Expect(GetMessage(obj, "trueInfo1-negative-polarity")).To(Equal("message trueInfo1-negative-polarity")) + + // test GetSeverity + expectedSeverity := clusterv1.ConditionSeverityInfo + g.Expect(GetSeverity(obj, "nil1")).To(BeNil()) + severity := GetSeverity(obj, "falseInfo1") + g.Expect(severity).To(Equal(&expectedSeverity)) + severity = GetSeverity(obj, "trueInfo1-negative-polarity") + g.Expect(severity).To(Equal(&expectedSeverity)) + + // test GetLastTransitionTime + g.Expect(GetLastTransitionTime(obj, "nil1")).To(BeNil()) + g.Expect(GetLastTransitionTime(obj, "falseInfo1")).ToNot(BeNil()) +} + +func TestMirror(t *testing.T) { + foo := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + ready := TrueCondition(clusterv1.ReadyCondition) + readyBar := ready.DeepCopy() + readyBar.Type = "bar" + + tests := []struct { + name string + from Getter + t clusterv1.ConditionType + want *clusterv1.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") + bar := FalseCondition("bar", "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1") + baz := FalseCondition("baz", "reason falseInfo2", clusterv1.ConditionSeverityInfo, "message falseInfo2") + fooWithNegativePolarity := FalseConditionWithNegativePolarity("foo-negative-polarity") + barWithNegativePolarity := TrueConditionWithNegativePolarity("bar-negative-polarity", "reason trueInfo1-negative-polarity", clusterv1.ConditionSeverityInfo, "message trueInfo1-negative-polarity") + existingReady := FalseCondition(clusterv1.ReadyCondition, "reason falseError1", clusterv1.ConditionSeverityError, "message falseError1") // NB. existing ready has higher priority than other conditions + + tests := []struct { + name string + from Getter + options []MergeOption + want *clusterv1.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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + { + name: "Returns ready condition with the summary of existing conditions with negative polarity (with default options)", + from: getterWithConditions(fooWithNegativePolarity, barWithNegativePolarity), + options: []MergeOption{WithNegativePolarityConditions("foo-negative-polarity", "bar-negative-polarity")}, + want: FalseCondition(clusterv1.ReadyCondition, "reason trueInfo1-negative-polarity", clusterv1.ConditionSeverityInfo, "message trueInfo1-negative-polarity"), + }, + { + name: "Returns ready condition with the summary of existing conditions with mixed polarity (with default options)", + from: getterWithConditions(foo, bar, fooWithNegativePolarity, barWithNegativePolarity), + options: []MergeOption{WithNegativePolarityConditions("foo-negative-polarity", "bar-negative-polarity")}, + want: FalseCondition(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), // bar take precedence on barWithNegativePolarity because it is listed first + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounter options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounter()}, + want: FalseCondition(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + { + name: "Returns ready condition with the summary of existing conditions (using WithStepCounterIf options)", + from: getterWithConditions(foo, bar), + options: []MergeOption{WithStepCounterIf(true)}, + want: FalseCondition(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + { + 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(clusterv1.ReadyCondition), + }, + { + name: "Returns ready condition with the summary of selected conditions with negative polarity (using WithConditions options)", + from: getterWithConditions(fooWithNegativePolarity, barWithNegativePolarity), + options: []MergeOption{WithConditions("foo-negative-polarity"), WithNegativePolarityConditions("foo-negative-polarity", "bar-negative-polarity")}, // bar-negative-polarity should be ignored because it is not listed in WithConditions + want: TrueCondition(clusterv1.ReadyCondition), + }, + { + name: "Returns ready condition with the summary of selected conditions with mixed polarity (using WithConditions options)", + from: getterWithConditions(foo, bar, fooWithNegativePolarity, barWithNegativePolarity), + options: []MergeOption{WithConditions("foo", "foo-negative-polarity", "bar-negative-polarity"), WithNegativePolarityConditions("foo-negative-polarity", "bar-negative-polarity")}, + want: FalseCondition(clusterv1.ReadyCondition, "reason trueInfo1-negative-polarity", clusterv1.ConditionSeverityInfo, "message trueInfo1-negative-polarity"), + }, + { + 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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "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(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + { + name: "Ready condition respects merge order", + from: getterWithConditions(bar, baz), + options: []MergeOption{WithConditions("baz", "bar")}, // baz should take precedence on bar + want: FalseCondition(clusterv1.ReadyCondition, "reason falseInfo2", clusterv1.ConditionSeverityInfo, "message falseInfo2"), + }, + { + name: "Ignores existing Ready condition when computing the summary", + from: getterWithConditions(existingReady, foo, bar), + want: FalseCondition(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := summary(tt.from, 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(clusterv1.ReadyCondition) + ready2 := FalseCondition(clusterv1.ReadyCondition, "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1") + bar := FalseCondition("bar", "reason falseError1", clusterv1.ConditionSeverityError, "message falseError1") // NB. bar has higher priority than other conditions + + tests := []struct { + name string + from []Getter + t clusterv1.ConditionType + want *clusterv1.Condition + }{ + { + name: "Returns nil when there are no conditions to aggregate", + from: []Getter{}, + want: nil, + }, + { + name: "Returns foo condition with the aggregation of object's ready conditions", + from: []Getter{ + getterWithConditions(ready1), + getterWithConditions(ready1), + getterWithConditions(ready2, bar), + getterWithConditions(), + getterWithConditions(bar), + }, + t: "foo", + want: FalseCondition("foo", "reason falseInfo1", clusterv1.ConditionSeverityInfo, "2 of 5 completed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := aggregate(tt.from, tt.t) + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func getterWithConditions(conditions ...*clusterv1.Condition) Getter { + obj := &clusterv1.Cluster{} + obj.SetConditions(conditionList(conditions...)) + return obj +} + +func nilGetter() Getter { + var obj *clusterv1.Cluster + return obj +} + +func conditionList(conditions ...*clusterv1.Condition) clusterv1.Conditions { + cs := clusterv1.Conditions{} + for _, x := range conditions { + if x != nil { + cs = append(cs, *x) + } + } + return cs +} diff --git a/util/deprecated/v1beta1/conditions/matcher.go b/util/deprecated/v1beta1/conditions/matcher.go new file mode 100644 index 000000000000..97fa86d35b88 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/matcher.go @@ -0,0 +1,103 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "fmt" + + "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// MatchConditions returns a custom matcher to check equality of clusterv1.Conditions. +func MatchConditions(expected clusterv1.Conditions) types.GomegaMatcher { + return &matchConditions{ + expected: expected, + } +} + +type matchConditions struct { + expected clusterv1.Conditions +} + +func (m matchConditions) Match(actual interface{}) (success bool, err error) { + elems := []interface{}{} + for _, condition := range m.expected { + elems = append(elems, MatchCondition(condition)) + } + + return gomega.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 clusterv1.Condition. +func MatchCondition(expected clusterv1.Condition) types.GomegaMatcher { + return &matchCondition{ + expected: expected, + } +} + +type matchCondition struct { + expected clusterv1.Condition +} + +func (m matchCondition) Match(actual interface{}) (success bool, err error) { + actualCondition, ok := actual.(clusterv1.Condition) + if !ok { + return false, fmt.Errorf("actual should be of type Condition") + } + + ok, err = gomega.Equal(m.expected.Type).Match(actualCondition.Type) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Status).Match(actualCondition.Status) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Severity).Match(actualCondition.Severity) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Reason).Match(actualCondition.Reason) + if !ok { + return ok, err + } + ok, err = gomega.Equal(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/util/deprecated/v1beta1/conditions/matcher_test.go b/util/deprecated/v1beta1/conditions/matcher_test.go new file mode 100644 index 000000000000..19b66b91d75f --- /dev/null +++ b/util/deprecated/v1beta1/conditions/matcher_test.go @@ -0,0 +1,317 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestMatchConditions(t *testing.T) { + testCases := []struct { + name string + actual interface{} + expected clusterv1.Conditions + expectMatch bool + }{ + { + name: "with an empty conditions", + actual: clusterv1.Conditions{}, + expected: clusterv1.Conditions{}, + expectMatch: true, + }, + { + name: "with matching conditions", + actual: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expectMatch: true, + }, + { + name: "with non-matching conditions", + actual: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: clusterv1.ConditionType("different"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "different", + Message: "different", + }, + }, + expectMatch: false, + }, + { + name: "with a different number of conditions", + actual: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + }, + expected: clusterv1.Conditions{ + { + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + 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 clusterv1.Condition + expectMatch bool + }{ + { + name: "with an empty condition", + actual: clusterv1.Condition{}, + expected: clusterv1.Condition{}, + expectMatch: true, + }, + { + name: "with a matching condition", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: true, + }, + { + name: "with a different time", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Time{}, + Reason: "reason", + Message: "message", + }, + expectMatch: true, + }, + { + name: "with a different type", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("different"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different status", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionFalse, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different severity", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityInfo, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different reason", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "different", + Message: "message", + }, + expectMatch: false, + }, + { + name: "with a different message", + actual: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "message", + }, + expected: clusterv1.Condition{ + Type: clusterv1.ConditionType("type"), + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityNone, + LastTransitionTime: metav1.Now(), + Reason: "reason", + Message: "different", + }, + 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(MatchCondition(tc.expected)) + } else { + g.Expect(tc.actual).ToNot(MatchCondition(tc.expected)) + } + }) + } +} diff --git a/util/deprecated/v1beta1/conditions/matchers.go b/util/deprecated/v1beta1/conditions/matchers.go new file mode 100644 index 000000000000..dfac4bb25614 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/matchers.go @@ -0,0 +1,53 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "errors" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// HaveSameStateOf matches a condition to have the same state of another. +func HaveSameStateOf(expected *clusterv1.Condition) types.GomegaMatcher { + return &conditionMatcher{ + Expected: expected, + } +} + +type conditionMatcher struct { + Expected *clusterv1.Condition +} + +func (matcher *conditionMatcher) Match(actual interface{}) (success bool, err error) { + actualCondition, ok := actual.(*clusterv1.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/util/deprecated/v1beta1/conditions/merge.go b/util/deprecated/v1beta1/conditions/merge.go new file mode 100644 index 000000000000..cf88621415fc --- /dev/null +++ b/util/deprecated/v1beta1/conditions/merge.go @@ -0,0 +1,231 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "sort" + + corev1 "k8s.io/api/core/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +type conditionPolarity string + +const ( + // PositivePolarity describe a condition with positive polarity (Status=True good). + PositivePolarity conditionPolarity = "Positive" + + // NegativePolarity describe a condition with negative polarity (Status=False good). + NegativePolarity conditionPolarity = "Negative" +) + +// localizedCondition defines a condition with the information of the object the conditions +// was originated from. +type localizedCondition struct { + *clusterv1.Condition + Polarity conditionPolarity + 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, severity +// 2. The resulting condition groups are sorted according to the following priority: +// - P0 - Status=False - PositivePolarity | Status=True - NegativePolarity, Severity=Error +// - P1 - Status=False - PositivePolarity | Status=True - NegativePolarity, Severity=Warning +// - P2 - Status=False - PositivePolarity | Status=True - NegativePolarity, Severity=Info +// - P3 - Status=True - PositivePolarity | Status=False - NegativePolarity +// - P4 - Status=Unknown +// +// 3. The group with highest priority is used to determine status, severity and other info of the target condition. +// +// 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. +// NOTE: Target condition will have positive polarity. +func merge(conditions []localizedCondition, targetCondition clusterv1.ConditionType, options *mergeOptions) *clusterv1.Condition { + g := getConditionGroups(conditions) + if len(g) == 0 { + return nil + } + + if g.TopGroup().status == corev1.ConditionTrue { + return TrueCondition(targetCondition) + } + + targetReason := getReason(g, options) + targetMessage := getMessage(g, options) + if g.TopGroup().status == corev1.ConditionFalse { + return FalseCondition(targetCondition, targetReason, g.TopGroup().severity, "%s", targetMessage) + } + return UnknownCondition(targetCondition, targetReason, "%s", targetMessage) +} + +// getConditionGroups groups a list of conditions according to status, severity values. +// Additionally, the resulting groups are sorted by mergePriority. +func getConditionGroups(conditions []localizedCondition) conditionGroups { + groups := conditionGroups{} + + for _, condition := range conditions { + if condition.Condition == nil { + continue + } + + added := false + + // Identify the groupStatus the condition belongs to. + // NOTE: status for the conditions with negative polarity is "negated" so it is possible + // to merge conditions with different polarity (conditionGroup is always using positive polarity). + groupStatus := condition.Status + if condition.Polarity == NegativePolarity { + switch groupStatus { + case corev1.ConditionFalse: + groupStatus = corev1.ConditionTrue + case corev1.ConditionTrue: + groupStatus = corev1.ConditionFalse + case corev1.ConditionUnknown: + groupStatus = corev1.ConditionUnknown + } + } + for i := range groups { + if groups[i].status == groupStatus && groups[i].severity == condition.Severity { + groups[i].conditions = append(groups[i].conditions, condition) + added = true + break + } + } + if !added { + groups = append(groups, conditionGroup{ + conditions: []localizedCondition{condition}, + status: groupStatus, + severity: condition.Severity, + }) + } + } + + // 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 condition group with the highest mergePriority. +func (g conditionGroups) TopGroup() *conditionGroup { + if len(g) == 0 { + return nil + } + return &g[0] +} + +// TrueGroup returns the condition group with status True, if any. +// Note: conditionGroup is always using positive polarity; the conditions in the group might have positive or negative polarity. +func (g conditionGroups) TrueGroup() *conditionGroup { + return g.getByStatusAndSeverity(corev1.ConditionTrue, clusterv1.ConditionSeverityNone) +} + +// ErrorGroup returns the condition group with status False and severity Error, if any. +// Note: conditionGroup is always using positive polarity; the conditions in the group might have positive or negative polarity. +func (g conditionGroups) ErrorGroup() *conditionGroup { + return g.getByStatusAndSeverity(corev1.ConditionFalse, clusterv1.ConditionSeverityError) +} + +// WarningGroup returns the condition group with status False and severity Warning, if any. +// Note: conditionGroup is always using positive polarity; the conditions in the group might have positive or negative polarity. +func (g conditionGroups) WarningGroup() *conditionGroup { + return g.getByStatusAndSeverity(corev1.ConditionFalse, clusterv1.ConditionSeverityWarning) +} + +func (g conditionGroups) getByStatusAndSeverity(status corev1.ConditionStatus, severity clusterv1.ConditionSeverity) *conditionGroup { + if len(g) == 0 { + return nil + } + for _, group := range g { + if group.status == status && group.severity == severity { + return &group + } + } + return nil +} + +// conditionGroup define a group of conditions with the same status and severity, +// and thus with the same priority when merging into a Ready condition. +// Note: conditionGroup is always using positive polarity; the conditions in the group might have positive or negative polarity. +type conditionGroup struct { + status corev1.ConditionStatus + severity clusterv1.ConditionSeverity + conditions []localizedCondition +} + +// mergePriority provides a priority value for the status and severity tuple that identifies this +// condition group. The mergePriority value allows an easier sorting of conditions groups. +// Note: conditionGroup is always using positive polarity. +func (g conditionGroup) mergePriority() int { + switch g.status { + case corev1.ConditionFalse: + switch g.severity { + case clusterv1.ConditionSeverityError: + return 0 + case clusterv1.ConditionSeverityWarning: + return 1 + case clusterv1.ConditionSeverityInfo: + return 2 + } + case corev1.ConditionTrue: + return 3 + case corev1.ConditionUnknown: + return 4 + } + + // this should never happen + return 99 +} diff --git a/util/deprecated/v1beta1/conditions/merge_strategies.go b/util/deprecated/v1beta1/conditions/merge_strategies.go new file mode 100644 index 000000000000..f4d038ff039f --- /dev/null +++ b/util/deprecated/v1beta1/conditions/merge_strategies.go @@ -0,0 +1,178 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "fmt" + "strings" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// 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 []clusterv1.ConditionType + negativeConditionTypes []clusterv1.ConditionType + addSourceRef bool + addStepCounter bool + addStepCounterIfOnlyConditionTypes []clusterv1.ConditionType + 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 (excepts Ready) 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 the Summary condition. +func WithConditions(t ...clusterv1.ConditionType) MergeOption { + return func(c *mergeOptions) { + c.conditionTypes = t + } +} + +// WithNegativePolarityConditions instruct merge about which conditions should be considered having negative polarity. +// IMPORTANT: this must be a subset of WithConditions. +func WithNegativePolarityConditions(t ...clusterv1.ConditionType) MergeOption { + return func(c *mergeOptions) { + c.negativeConditionTypes = 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. +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 options works only while generating the Summary condition. +func WithStepCounterIf(value bool) MergeOption { + return func(c *mergeOptions) { + c.addStepCounter = value + } +} + +// WithStepCounterIfOnly ensure a step counter is show only if a subset of condition exists. +// This applies for example on Machines, where we want to use +// the step counter notation while provisioning the machine, but then we want to move away from this notation +// as soon as the machine is provisioned and e.g. a Machine health check condition is generated +// +// IMPORTANT: This options requires WithStepCounter or WithStepCounterIf to be set. +// IMPORTANT: This options works only while generating the Summary condition. +func WithStepCounterIfOnly(t ...clusterv1.ConditionType) MergeOption { + return func(c *mergeOptions) { + c.addStepCounterIfOnlyConditionTypes = t + } +} + +// AddSourceRef instructs merge to add info about the originating object to the target Reason. +func AddSourceRef() MergeOption { + return func(c *mergeOptions) { + c.addSourceRef = true + } +} + +// 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 []clusterv1.ConditionType, 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) + } + + return getFirstMessage(groups, options.conditionTypes) +} + +// getStepCounterMessage returns a message "x of y completed", where x is the number of conditions +// with Status=true and y is the number passed to this method. +func getStepCounterMessage(groups conditionGroups, to int) string { + ct := 0 + if trueGroup := groups.TrueGroup(); 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 []clusterv1.ConditionType) 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 []clusterv1.ConditionType) *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/util/deprecated/v1beta1/conditions/merge_strategies_test.go b/util/deprecated/v1beta1/conditions/merge_strategies_test.go new file mode 100644 index 000000000000..516f1a45c628 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/merge_strategies_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestGetStepCounterMessage(t *testing.T) { + g := NewWithT(t) + + groups := getConditionGroups(conditionsWithSource(&clusterv1.Cluster{}, + nil1, + true1, true1, + falseInfo1, + falseWarning1, falseWarning1, + falseError1, + unknown1, + )) + + 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 := &clusterv1.Cluster{ + TypeMeta: metav1.TypeMeta{ + Kind: "Cluster", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + }, + } + + // localize should reason location + got := localizeReason("foo", getter) + g.Expect(got).To(Equal("foo @ Cluster/test-cluster")) + + // 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", clusterv1.ConditionSeverityInfo, "message falseFoo") + bar := FalseCondition("bar", "falseBar", clusterv1.ConditionSeverityInfo, "message falseBar") + + getter := &clusterv1.Cluster{ + TypeMeta: metav1.TypeMeta{ + Kind: "Cluster", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + }, + } + + groups := getConditionGroups(conditionsWithSource(getter, foo, bar)) + + // 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, []clusterv1.ConditionType{"foo", "bar"}, false) + g.Expect(gotReason).To(Equal("falseFoo")) + gotMessage = getFirstMessage(groups, []clusterv1.ConditionType{"foo", "bar"}) + g.Expect(gotMessage).To(Equal("message falseFoo")) + + // getFirst should report should respect order in case of missing conditions + gotReason = getFirstReason(groups, []clusterv1.ConditionType{"missingBaz", "foo", "bar"}, false) + g.Expect(gotReason).To(Equal("falseFoo")) + gotMessage = getFirstMessage(groups, []clusterv1.ConditionType{"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, []clusterv1.ConditionType{"missingBaz"}, false) + g.Expect(gotReason).To(Equal("falseBar")) + gotMessage = getFirstMessage(groups, []clusterv1.ConditionType{"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 @ Cluster/test-cluster")) +} diff --git a/util/deprecated/v1beta1/conditions/merge_test.go b/util/deprecated/v1beta1/conditions/merge_test.go new file mode 100644 index 000000000000..36ebecaac614 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/merge_test.go @@ -0,0 +1,285 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestNewConditionsGroup(t *testing.T) { + t.Run("Positive polarity", func(t *testing.T) { + g := NewWithT(t) + + conditions := []*clusterv1.Condition{nil1, true1, true1, falseInfo1, falseWarning1, falseWarning1, falseError1, unknown1} + + got := getConditionGroups(conditionsWithSource(&clusterv1.Cluster{}, conditions...)) + + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(HaveLen(5)) + + // The top group should be False/Error and it should have one condition + g.Expect(got.TopGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.TopGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.TopGroup().conditions).To(HaveLen(1)) + + // The true group should be true and it should have two conditions + g.Expect(got.TrueGroup().status).To(Equal(corev1.ConditionTrue)) + g.Expect(got.TrueGroup().severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got.TrueGroup().conditions).To(HaveLen(2)) + + // The error group should be False/Error and it should have one condition + g.Expect(got.ErrorGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.ErrorGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.ErrorGroup().conditions).To(HaveLen(1)) + + // The warning group should be False/Warning and it should have two conditions + g.Expect(got.WarningGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.WarningGroup().severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got.WarningGroup().conditions).To(HaveLen(2)) + + // got[0] should be False/Error and it should have one condition + g.Expect(got[0].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[0].severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got[0].conditions).To(HaveLen(1)) + + // got[1] should be False/Warning and it should have two conditions + g.Expect(got[1].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[1].severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got[1].conditions).To(HaveLen(2)) + + // got[2] should be False/Info and it should have one condition + g.Expect(got[2].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[2].severity).To(Equal(clusterv1.ConditionSeverityInfo)) + g.Expect(got[2].conditions).To(HaveLen(1)) + + // got[3] should be True and it should have two conditions + g.Expect(got[3].status).To(Equal(corev1.ConditionTrue)) + g.Expect(got[3].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[3].conditions).To(HaveLen(2)) + + // got[4] should be Unknown and it should have one condition + g.Expect(got[4].status).To(Equal(corev1.ConditionUnknown)) + g.Expect(got[4].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[4].conditions).To(HaveLen(1)) + + // nil conditions are ignored + }) + t.Run("Negative polarity", func(t *testing.T) { + g := NewWithT(t) + + conditions := []*clusterv1.Condition{nil1, false1WithNegativePolarity, false1WithNegativePolarity, trueInfo1WithNegativePolarity, trueWarning1WithNegativePolarity, trueWarning1WithNegativePolarity, trueError1WithNegativePolarity, unknown1WithNegativePolarity} + + got := getConditionGroups(conditionsWithSource(&clusterv1.Cluster{}, conditions...)) + + // NOTE: groups always have a positive polarity + + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(HaveLen(5)) + + // The top group should be False/Error and it should have one condition + g.Expect(got.TopGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.TopGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.TopGroup().conditions).To(HaveLen(1)) + + // The true group should be true and it should have two conditions + g.Expect(got.TrueGroup().status).To(Equal(corev1.ConditionTrue)) + g.Expect(got.TrueGroup().severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got.TrueGroup().conditions).To(HaveLen(2)) + + // The error group should be False/Error and it should have one condition + g.Expect(got.ErrorGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.ErrorGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.ErrorGroup().conditions).To(HaveLen(1)) + + // The warning group should be False/Warning and it should have two conditions + g.Expect(got.WarningGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.WarningGroup().severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got.WarningGroup().conditions).To(HaveLen(2)) + + // got[0] should be False/Error and it should have one condition + g.Expect(got[0].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[0].severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got[0].conditions).To(HaveLen(1)) + + // got[1] should be False/Warning and it should have two conditions + g.Expect(got[1].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[1].severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got[1].conditions).To(HaveLen(2)) + + // got[2] should be False/Info and it should have one condition + g.Expect(got[2].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[2].severity).To(Equal(clusterv1.ConditionSeverityInfo)) + g.Expect(got[2].conditions).To(HaveLen(1)) + + // got[3] should be True and it should have two conditions + g.Expect(got[3].status).To(Equal(corev1.ConditionTrue)) + g.Expect(got[3].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[3].conditions).To(HaveLen(2)) + + // got[4] should be Unknown and it should have one condition + g.Expect(got[4].status).To(Equal(corev1.ConditionUnknown)) + g.Expect(got[4].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[4].conditions).To(HaveLen(1)) + + // nil conditions are ignored + }) + t.Run("Mixed polarity", func(t *testing.T) { + g := NewWithT(t) + + conditions := []*clusterv1.Condition{nil1, true1, true1, falseInfo1, falseWarning1, falseWarning1, falseError1, unknown1, false1WithNegativePolarity, false1WithNegativePolarity, trueInfo1WithNegativePolarity, trueWarning1WithNegativePolarity, trueWarning1WithNegativePolarity, trueError1WithNegativePolarity, unknown1WithNegativePolarity} + + got := getConditionGroups(conditionsWithSource(&clusterv1.Cluster{}, conditions...)) + + g.Expect(got).ToNot(BeNil()) + g.Expect(got).To(HaveLen(5)) + + // The top group should be False/Error and it should have two condition + g.Expect(got.TopGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.TopGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.TopGroup().conditions).To(HaveLen(2)) + + // The true group should be true and it should have four conditions + g.Expect(got.TrueGroup().status).To(Equal(corev1.ConditionTrue)) + g.Expect(got.TrueGroup().severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got.TrueGroup().conditions).To(HaveLen(4)) + + // The error group should be False/Error and it should have two condition + g.Expect(got.ErrorGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.ErrorGroup().severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got.ErrorGroup().conditions).To(HaveLen(2)) + + // The warning group should be False/Warning and it should have four conditions + g.Expect(got.WarningGroup().status).To(Equal(corev1.ConditionFalse)) + g.Expect(got.WarningGroup().severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got.WarningGroup().conditions).To(HaveLen(4)) + + // got[0] should be False/Error and it should have two condition + g.Expect(got[0].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[0].severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(got[0].conditions).To(HaveLen(2)) + + // got[1] should be False/Warning and it should have four conditions + g.Expect(got[1].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[1].severity).To(Equal(clusterv1.ConditionSeverityWarning)) + g.Expect(got[1].conditions).To(HaveLen(4)) + + // got[2] should be False/Info and it should have two condition + g.Expect(got[2].status).To(Equal(corev1.ConditionFalse)) + g.Expect(got[2].severity).To(Equal(clusterv1.ConditionSeverityInfo)) + g.Expect(got[2].conditions).To(HaveLen(2)) + + // got[3] should be True and it should have four conditions + g.Expect(got[3].status).To(Equal(corev1.ConditionTrue)) + g.Expect(got[3].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[3].conditions).To(HaveLen(4)) + + // got[4] should be Unknown and it should have two condition + g.Expect(got[4].status).To(Equal(corev1.ConditionUnknown)) + g.Expect(got[4].severity).To(Equal(clusterv1.ConditionSeverityNone)) + g.Expect(got[4].conditions).To(HaveLen(2)) + + // nil conditions are ignored + }) +} + +func TestMergeRespectPriority(t *testing.T) { + tests := []struct { + name string + conditions []*clusterv1.Condition + want *clusterv1.Condition + }{ + { + name: "aggregate nil list return nil", + conditions: nil, + want: nil, + }, + { + name: "aggregate empty list return nil", + conditions: []*clusterv1.Condition{}, + want: nil, + }, + { + name: "When there is false/error it returns false/error", + conditions: []*clusterv1.Condition{falseError1, falseWarning1, falseInfo1, unknown1, true1}, + want: FalseCondition("foo", "reason falseError1", clusterv1.ConditionSeverityError, "message falseError1"), + }, + { + name: "When there is false/warning and no false/error, it returns false/warning", + conditions: []*clusterv1.Condition{falseWarning1, falseInfo1, unknown1, true1}, + want: FalseCondition("foo", "reason falseWarning1", clusterv1.ConditionSeverityWarning, "message falseWarning1"), + }, + { + name: "When there is false/info and no false/error or false/warning, it returns false/info", + conditions: []*clusterv1.Condition{falseInfo1, unknown1, true1}, + want: FalseCondition("foo", "reason falseInfo1", clusterv1.ConditionSeverityInfo, "message falseInfo1"), + }, + { + name: "When there is true and no false/*, it returns info", + conditions: []*clusterv1.Condition{unknown1, true1}, + want: TrueCondition("foo"), + }, + { + name: "When there is unknown and no true or false/*, it returns unknown", + conditions: []*clusterv1.Condition{unknown1}, + want: UnknownCondition("foo", "reason unknown1", "message unknown1"), + }, + { + name: "nil conditions are ignored", + conditions: []*clusterv1.Condition{nil1, nil1, nil1}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := merge(conditionsWithSource(&clusterv1.Cluster{}, tt.conditions...), "foo", &mergeOptions{}) + + if tt.want == nil { + g.Expect(got).To(BeNil()) + return + } + g.Expect(got).To(HaveSameStateOf(tt.want)) + }) + } +} + +func conditionsWithSource(obj Setter, conditions ...*clusterv1.Condition) []localizedCondition { + obj.SetConditions(conditionList(conditions...)) + + ret := []localizedCondition{} + for i := range conditions { + polarity := PositivePolarity + if conditions[i] != nil && negativePolarityConditions.Has(string(conditions[i].Type)) { + polarity = NegativePolarity + } + + ret = append(ret, localizedCondition{ + Condition: conditions[i], + Polarity: polarity, + Getter: obj, + }) + } + + return ret +} diff --git a/util/deprecated/v1beta1/conditions/patch.go b/util/deprecated/v1beta1/conditions/patch.go new file mode 100644 index 000000000000..46eb0abda67f --- /dev/null +++ b/util/deprecated/v1beta1/conditions/patch.go @@ -0,0 +1,219 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "reflect" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util" +) + +// 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 *clusterv1.Condition + After *clusterv1.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 Patch required to align source conditions to after conditions. +func NewPatch(before Getter, after Getter) (Patch, error) { + var patch Patch + + if util.IsNil(before) { + return nil, errors.New("error creating patch: before object is nil") + } + if util.IsNil(after) { + return nil, errors.New("error creating patch: after object is nil") + } + + // 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, nil +} + +// applyOptions allows to set strategies for patch apply. +type applyOptions struct { + ownedConditions []clusterv1.ConditionType + forceOverwrite bool +} + +func (o *applyOptions) isOwnedCondition(t clusterv1.ConditionType) 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 ...clusterv1.ConditionType) ApplyOption { + return func(c *applyOptions) { + c.ownedConditions = t + } +} + +// WithForceOverwrite In case of conflicts for the owned conditions, the patch helper will always use the value provided by the controller. +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 p.IsZero() { + return nil + } + + if util.IsNil(latest) { + return errors.New("error patching conditions: latest object was nil") + } + + applyOpt := &applyOptions{} + for _, o := range options { + if util.IsNil(o) { + return errors.New("error patching conditions: ApplyOption was nil") + } + 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 is nil or has no changes. +func (p Patch) IsZero() bool { + if p == nil { + return true + } + return len(p) == 0 +} diff --git a/util/deprecated/v1beta1/conditions/patch_test.go b/util/deprecated/v1beta1/conditions/patch_test.go new file mode 100644 index 000000000000..eda543bef3ef --- /dev/null +++ b/util/deprecated/v1beta1/conditions/patch_test.go @@ -0,0 +1,341 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestNewPatch(t *testing.T) { + fooTrue := TrueCondition("foo") + fooFalse := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + + tests := []struct { + name string + before Getter + after Getter + want Patch + wantErr bool + }{ + { + name: "nil before return error", + before: nil, + after: getterWithConditions(), + wantErr: true, + }, + { + name: "nil after return error", + before: getterWithConditions(), + after: nil, + wantErr: true, + }, + { + name: "nil Interface before return error", + before: nilGetter(), + after: getterWithConditions(), + wantErr: true, + }, + { + name: "nil Interface after return error", + before: getterWithConditions(), + after: nilGetter(), + wantErr: true, + }, + { + name: "No changes return empty patch", + before: getterWithConditions(), + after: getterWithConditions(), + want: nil, + wantErr: false, + }, + + { + 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, err := NewPatch(tt.before, tt.after) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(Not(HaveOccurred())) + g.Expect(got).To(BeComparableTo(tt.want)) + }) + } +} + +func TestApply(t *testing.T) { + fooTrue := TrueCondition("foo") + fooFalse := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + fooWarning := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityWarning, "message foo") + + tests := []struct { + name string + before Getter + after Getter + latest Setter + options []ApplyOption + want clusterv1.Conditions + wantErr bool + }{ + { + name: "error with nil interface Setter", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: nilSetter(), + want: conditionList(fooTrue), + wantErr: true, + }, + { + name: "error with nil Setter", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: nil, + want: conditionList(fooTrue), + wantErr: true, + }, + { + 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(fooWarning), + 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(fooWarning), + 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, + }, + { + name: "Error when nil passed as an ApplyOption", + before: getterWithConditions(fooTrue), + after: getterWithConditions(fooFalse), + latest: setterWithConditions(), + options: []ApplyOption{nil}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Ignore the error here to allow testing of patch.Apply with a nil patch + 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 := &clusterv1.Cluster{} + after := &clusterv1.Cluster{ + Status: clusterv1.ClusterStatus{ + Conditions: clusterv1.Conditions{ + clusterv1.Condition{ + Type: "foo", + Status: corev1.ConditionTrue, + LastTransitionTime: metav1.NewTime(time.Now().UTC().Truncate(time.Second)), + }, + }, + }, + } + latest := &clusterv1.Cluster{} + + // latest has no conditions, so we are actually adding the condition but in this case we should not set the LastTransition Time + // but we should preserve the LastTransition set in after + + diff, err := NewPatch(before, after) + g.Expect(err).ToNot(HaveOccurred()) + err = diff.Apply(latest) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(latest.GetConditions()).To(BeComparableTo(after.GetConditions())) +} diff --git a/util/deprecated/v1beta1/conditions/setter.go b/util/deprecated/v1beta1/conditions/setter.go new file mode 100644 index 000000000000..8cea760581e5 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/setter.go @@ -0,0 +1,262 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "fmt" + "sort" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// Setter interface defines methods that a Cluster API object should implement in order to +// use the conditions package for setting conditions. +type Setter interface { + Getter + SetConditions(clusterv1.Conditions) +} + +// 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, Severity and Message. +func Set(to Setter, condition *clusterv1.Condition) { + if to == nil || condition == nil { + return + } + + // 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) +} + +// SetWithCustomLastTransitionTime is similar to Set function which sets the given condition but following changes for LastTransitionTime. +// +// 1. if the condition of the specified type already exists (all fields of the existing condition are updated to +// new condition, LastTransitionTime is set to current time if unset and new status differs from the old status) +// 2. if a condition of the specified type does not exist (LastTransitionTime is set to current time if unset, and newCondition is appended) +func SetWithCustomLastTransitionTime(to Setter, condition *clusterv1.Condition) { + if to == nil || condition == nil { + return + } + + // 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) { + if existingCondition.Status != condition.Status { + if condition.LastTransitionTime.IsZero() { + condition.LastTransitionTime = metav1.NewTime(time.Now().UTC().Truncate(time.Second)) + } + } else { + condition.LastTransitionTime = existingCondition.LastTransitionTime + } + conditions[i] = *condition + } + 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. +func TrueCondition(t clusterv1.ConditionType) *clusterv1.Condition { + return &clusterv1.Condition{ + Type: t, + Status: corev1.ConditionTrue, + } +} + +// TrueConditionWithNegativePolarity returns a condition with negative polarity, Status=True and the given type (Status=True has a negative meaning). +func TrueConditionWithNegativePolarity(t clusterv1.ConditionType, reason string, severity clusterv1.ConditionSeverity, messageFormat string, messageArgs ...interface{}) *clusterv1.Condition { + return &clusterv1.Condition{ + Type: t, + Status: corev1.ConditionTrue, + Reason: reason, + Severity: severity, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// FalseCondition returns a condition with Status=False and the given type. +func FalseCondition(t clusterv1.ConditionType, reason string, severity clusterv1.ConditionSeverity, messageFormat string, messageArgs ...interface{}) *clusterv1.Condition { + return &clusterv1.Condition{ + Type: t, + Status: corev1.ConditionFalse, + Reason: reason, + Severity: severity, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// FalseConditionWithNegativePolarity returns a condition with negative polarity, Status=false and the given type (Status=False has a positive meaning). +func FalseConditionWithNegativePolarity(t clusterv1.ConditionType) *clusterv1.Condition { + return &clusterv1.Condition{ + Type: t, + Status: corev1.ConditionFalse, + } +} + +// UnknownCondition returns a condition with Status=Unknown and the given type. +func UnknownCondition(t clusterv1.ConditionType, reason string, messageFormat string, messageArgs ...interface{}) *clusterv1.Condition { + return &clusterv1.Condition{ + Type: t, + Status: corev1.ConditionUnknown, + Reason: reason, + Message: fmt.Sprintf(messageFormat, messageArgs...), + } +} + +// MarkTrue sets Status=True for the condition with the given type. +func MarkTrue(to Setter, t clusterv1.ConditionType) { + Set(to, TrueCondition(t)) +} + +// MarkTrueWithNegativePolarity sets Status=True for a condition with negative polarity and the given type (Status=True has a negative meaning). +func MarkTrueWithNegativePolarity(to Setter, t clusterv1.ConditionType, reason string, severity clusterv1.ConditionSeverity, messageFormat string, messageArgs ...interface{}) { + Set(to, TrueConditionWithNegativePolarity(t, reason, severity, messageFormat, messageArgs...)) +} + +// MarkUnknown sets Status=Unknown for the condition with the given type. +func MarkUnknown(to Setter, t clusterv1.ConditionType, reason, messageFormat string, messageArgs ...interface{}) { + Set(to, UnknownCondition(t, reason, messageFormat, messageArgs...)) +} + +// MarkFalse sets Status=False for the condition with the given type. +func MarkFalse(to Setter, t clusterv1.ConditionType, reason string, severity clusterv1.ConditionSeverity, messageFormat string, messageArgs ...interface{}) { + Set(to, FalseCondition(t, reason, severity, messageFormat, messageArgs...)) +} + +// MarkFalseWithNegativePolarity sets Status=False for a condition with negative polarity and the given type (Status=False has a positive meaning). +func MarkFalseWithNegativePolarity(to Setter, t clusterv1.ConditionType) { + Set(to, FalseConditionWithNegativePolarity(t)) +} + +// SetSummary sets a Ready 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, options ...MergeOption) { + Set(to, summary(to, options...)) +} + +// SetMirror creates a new condition by mirroring the Ready condition from a dependent object; +// if the Ready condition does not exist in the source object, no target conditions is generated. +func SetMirror(to Setter, targetCondition clusterv1.ConditionType, from Getter, options ...MirrorOptions) { + Set(to, mirror(from, targetCondition, options...)) +} + +// SetAggregate creates a new condition with the aggregation of all the Ready condition +// from a list of dependent objects; if the Ready condition does not exist in one of the source object, +// the object is excluded from the aggregation; if none of the source object have ready condition, +// no target conditions is generated. +func SetAggregate(to Setter, targetCondition clusterv1.ConditionType, from []Getter, options ...MergeOption) { + Set(to, aggregate(from, targetCondition, options...)) +} + +// Delete deletes the condition with the given type. +func Delete(to Setter, t clusterv1.ConditionType) { + if to == nil { + return + } + + conditions := to.GetConditions() + newConditions := make(clusterv1.Conditions, 0, len(conditions)) + for _, condition := range conditions { + if condition.Type != t { + newConditions = append(newConditions, condition) + } + } + to.SetConditions(newConditions) +} + +// lexicographicLess returns true if a condition is less than another in regard to +// the order of conditions designed for convenience of the consumer, i.e. kubectl. +// According to this order the Ready condition always goes first, followed by all the other +// conditions sorted by Type. +func lexicographicLess(i, j *clusterv1.Condition) bool { + if i == nil { + return true + } + if j == nil { + return false + } + return (i.Type == clusterv1.ReadyCondition || i.Type < j.Type) && j.Type != clusterv1.ReadyCondition +} + +// HasSameState returns true if a condition has the same state of another; state is defined +// by the union of following fields: Type, Status, Reason, Severity and Message (it excludes LastTransitionTime). +func HasSameState(i, j *clusterv1.Condition) bool { + if i == nil || j == nil { + return i == j + } + return i.Type == j.Type && + i.Status == j.Status && + i.Reason == j.Reason && + i.Severity == j.Severity && + i.Message == j.Message +} diff --git a/util/deprecated/v1beta1/conditions/setter_test.go b/util/deprecated/v1beta1/conditions/setter_test.go new file mode 100644 index 000000000000..65f9ac5d5d97 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/setter_test.go @@ -0,0 +1,425 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestHasSameState(t *testing.T) { + g := NewWithT(t) + + // two nils + var nil2 *clusterv1.Condition + g.Expect(HasSameState(nil1, nil2)).To(BeTrue()) + + // nil condition 1 + g.Expect(HasSameState(nil1, true1)).To(BeFalse()) + + // nil condition 2 + g.Expect(HasSameState(true1, nil2)).To(BeFalse()) + + // same condition + falseInfo2 := falseInfo1.DeepCopy() + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeTrue()) + + // different LastTransitionTime does not impact state + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.LastTransitionTime = metav1.NewTime(time.Date(1900, time.November, 10, 23, 0, 0, 0, time.UTC)) + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeTrue()) + + // different Type, Status, Reason, Severity and Message determine different state + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.Type = "another type" + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeFalse()) + + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.Status = corev1.ConditionTrue + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeFalse()) + + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.Severity = clusterv1.ConditionSeverityWarning + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeFalse()) + + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.Reason = "another severity" + g.Expect(HasSameState(falseInfo1, falseInfo2)).To(BeFalse()) + + falseInfo2 = falseInfo1.DeepCopy() + falseInfo2.Message = "another message" + g.Expect(HasSameState(falseInfo1, falseInfo2)).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()) + + // Ready condition is treated as an exception and always goes first + a = TrueCondition(clusterv1.ReadyCondition) + b = TrueCondition("A") + g.Expect(lexicographicLess(a, b)).To(BeTrue()) + + a = TrueCondition("A") + b = TrueCondition(clusterv1.ReadyCondition) + g.Expect(lexicographicLess(a, b)).To(BeFalse()) + + a = TrueCondition("A") + g.Expect(lexicographicLess(a, nil1)).To(BeFalse()) + + b = TrueCondition("A") + g.Expect(lexicographicLess(nil1, b)).To(BeTrue()) +} + +func TestSet(t *testing.T) { + a := TrueCondition("a") + b := TrueCondition("b") + ready := TrueCondition(clusterv1.ReadyCondition) + + tests := []struct { + name string + to Setter + condition *clusterv1.Condition + want clusterv1.Conditions + }{ + { + 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", clusterv1.ConditionSeverityInfo, "message foo") + fooWithLastTransitionTime := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + fooWithLastTransitionTime.LastTransitionTime = x + fooWithAnotherState := TrueCondition("foo") + + tests := []struct { + name string + to Setter + new *clusterv1.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 TestSetWithCustomLastTransitionTime(t *testing.T) { + x := metav1.Date(2012, time.January, 1, 12, 15, 30, 5e8, time.UTC) + y := metav1.Date(2012, time.January, 2, 12, 15, 30, 5e8, time.UTC) + + foo := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + fooWithBarMessage := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message bar") + fooWithLastTransitionTime := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message foo") + fooWithLastTransitionTime.LastTransitionTime = x + fooWithLastTransitionTimeWithBarMessage := FalseCondition("foo", "reason foo", clusterv1.ConditionSeverityInfo, "message bar") + fooWithLastTransitionTimeWithBarMessage.LastTransitionTime = y + + fooWithAnotherState := TrueCondition("foo") + fooWithAnotherStateWithLastTransitionTime := TrueCondition("foo") + fooWithAnotherStateWithLastTransitionTime.LastTransitionTime = y + + tests := []struct { + name string + to Setter + new *clusterv1.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)) + }, + }, + { + name: "Set a condition that already exists but with different state should preserve the last transition time if defined", + to: setterWithConditions(fooWithLastTransitionTime), + new: fooWithAnotherStateWithLastTransitionTime, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).To(Equal(y)) + }, + }, + { + name: "Set a condition that already exists but with different Message should preserve the last transition time", + to: setterWithConditions(fooWithLastTransitionTime), + new: fooWithBarMessage, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).To(Equal(x)) + }, + }, + { + name: "Set a condition that already exists, with different state but same Status should ignore the last transition even if defined", + to: setterWithConditions(fooWithLastTransitionTime), + new: fooWithLastTransitionTimeWithBarMessage, + LastTransitionTimeCheck: func(g *WithT, lastTransitionTime metav1.Time) { + g.Expect(lastTransitionTime).To(Equal(x)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + SetWithCustomLastTransitionTime(tt.to, tt.new) + + tt.LastTransitionTimeCheck(g, Get(tt.to, "foo").LastTransitionTime) + }) + } +} + +func TestMarkMethods(t *testing.T) { + g := NewWithT(t) + + cluster := &clusterv1.Cluster{} + + // test MarkTrue + MarkTrue(cluster, "conditionFoo") + g.Expect(Get(cluster, "conditionFoo")).To(HaveSameStateOf(&clusterv1.Condition{ + Type: "conditionFoo", + Status: corev1.ConditionTrue, + })) + + // test MarkFalse + MarkFalse(cluster, "conditionBar", "reasonBar", clusterv1.ConditionSeverityError, "messageBar") + g.Expect(Get(cluster, "conditionBar")).To(HaveSameStateOf(&clusterv1.Condition{ + Type: "conditionBar", + Status: corev1.ConditionFalse, + Severity: clusterv1.ConditionSeverityError, + Reason: "reasonBar", + Message: "messageBar", + })) + + // test MarkUnknown + MarkUnknown(cluster, "conditionBaz", "reasonBaz", "messageBaz") + g.Expect(Get(cluster, "conditionBaz")).To(HaveSameStateOf(&clusterv1.Condition{ + Type: "conditionBaz", + Status: corev1.ConditionUnknown, + Reason: "reasonBaz", + Message: "messageBaz", + })) + + // test MarkFalseWithNegativePolarity + MarkFalseWithNegativePolarity(cluster, "conditionFoo") + g.Expect(Get(cluster, "conditionFoo")).To(HaveSameStateOf(&clusterv1.Condition{ + Type: "conditionFoo", + Status: corev1.ConditionFalse, + })) + + // test MarkTrueWithNegativePolarity + MarkTrueWithNegativePolarity(cluster, "conditionBar", "reasonBar", clusterv1.ConditionSeverityError, "messageBar") + g.Expect(Get(cluster, "conditionBar")).To(HaveSameStateOf(&clusterv1.Condition{ + Type: "conditionBar", + Status: corev1.ConditionTrue, + Severity: clusterv1.ConditionSeverityError, + Reason: "reasonBar", + Message: "messageBar", + })) +} + +func TestSetSummary(t *testing.T) { + g := NewWithT(t) + target := setterWithConditions(TrueCondition("foo")) + + SetSummary(target) + + g.Expect(Has(target, clusterv1.ReadyCondition)).To(BeTrue()) +} + +func TestSetMirror(t *testing.T) { + g := NewWithT(t) + source := getterWithConditions(TrueCondition(clusterv1.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(clusterv1.ReadyCondition)) + source2 := getterWithConditions(TrueCondition(clusterv1.ReadyCondition)) + target := setterWithConditions() + + SetAggregate(target, "foo", []Getter{source1, source2}) + + g.Expect(Has(target, "foo")).To(BeTrue()) +} + +func setterWithConditions(conditions ...*clusterv1.Condition) Setter { + obj := &clusterv1.Cluster{} + obj.SetConditions(conditionList(conditions...)) + return obj +} + +func nilSetter() Setter { + var obj *clusterv1.Cluster + return obj +} + +func haveSameConditionsOf(expected clusterv1.Conditions) types.GomegaMatcher { + return &ConditionsMatcher{ + Expected: expected, + } +} + +type ConditionsMatcher struct { + Expected clusterv1.Conditions +} + +func (matcher *ConditionsMatcher) Match(actual interface{}) (success bool, err error) { + actualConditions, ok := actual.(clusterv1.Conditions) + 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/util/deprecated/v1beta1/conditions/suite_test.go b/util/deprecated/v1beta1/conditions/suite_test.go new file mode 100644 index 000000000000..a03ec411fe3b --- /dev/null +++ b/util/deprecated/v1beta1/conditions/suite_test.go @@ -0,0 +1,28 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes/scheme" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func init() { + utilruntime.Must(clusterv1.AddToScheme(scheme.Scheme)) +} diff --git a/util/deprecated/v1beta1/conditions/unstructured.go b/util/deprecated/v1beta1/conditions/unstructured.go new file mode 100644 index 000000000000..3bc3bc7dda55 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/unstructured.go @@ -0,0 +1,84 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package conditions + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/log" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util" +) + +// UnstructuredGetter return a Getter object that can read conditions from an Unstructured object. +// Important. This method should be used only with types implementing Cluster API conditions. +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 Cluster API conditions. +func UnstructuredSetter(u *unstructured.Unstructured) Setter { + return &unstructuredWrapper{Unstructured: u} +} + +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 is returned. +// - If the object doesn't implement conditions on under status as defined in Cluster API, +// JSON-unmarshal matches incoming object keys to the keys; this can lead to conditions values partially set. +func (c *unstructuredWrapper) GetConditions() clusterv1.Conditions { + conditions := clusterv1.Conditions{} + if err := util.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 clusterv1.Conditions) { + 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/util/deprecated/v1beta1/conditions/unstructured_test.go b/util/deprecated/v1beta1/conditions/unstructured_test.go new file mode 100644 index 000000000000..acaca1230475 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/unstructured_test.go @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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/client-go/kubernetes/scheme" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestUnstructuredGetConditions(t *testing.T) { + g := NewWithT(t) + + // GetConditions should return conditions from an unstructured object + c := &clusterv1.Cluster{} + c.SetConditions(conditionList(true1)) + u := &unstructured.Unstructured{} + g.Expect(scheme.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 = &clusterv1.Cluster{} + u = &unstructured.Unstructured{} + g.Expect(scheme.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.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.Scheme.Convert(p, u, nil)).To(Succeed()) + + g.Expect(UnstructuredGetter(u).GetConditions()).To(HaveLen(1)) +} + +func TestUnstructuredSetConditions(t *testing.T) { + g := NewWithT(t) + + c := &clusterv1.Cluster{} + u := &unstructured.Unstructured{} + g.Expect(scheme.Scheme.Convert(c, u, nil)).To(Succeed()) + + // set conditions + conditions := conditionList(true1, falseInfo1) + + s := UnstructuredSetter(u) + s.SetConditions(conditions) + g.Expect(s.GetConditions()).To(BeComparableTo(conditions)) +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/aggregate.go b/util/deprecated/v1beta1/conditions/v1beta2/aggregate.go new file mode 100644 index 000000000000..892fd9a9ce38 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/aggregate.go @@ -0,0 +1,138 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "fmt" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +// AggregateOption is some configuration that modifies options for a aggregate call. +type AggregateOption interface { + // ApplyToAggregate applies this configuration to the given aggregate options. + ApplyToAggregate(option *AggregateOptions) +} + +// AggregateOptions allows to set options for the aggregate operation. +type AggregateOptions struct { + mergeStrategy MergeStrategy + targetConditionType string + negativePolarityConditionTypes []string +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *AggregateOptions) ApplyOptions(opts []AggregateOption) *AggregateOptions { + for _, opt := range opts { + opt.ApplyToAggregate(o) + } + return o +} + +// NewAggregateCondition aggregates a condition from a list of objects; the given condition must have positive polarity; +// if the given condition does not exist in one of the source objects, missing conditions are considered Unknown, reason NotYetReported. +// +// If the source conditions type has negative polarity, it should be indicated with the NegativePolarityConditionTypes option; +// in this case NegativePolarityConditionTypes must match the sourceConditionType, and by default also the resulting condition +// will have negative polarity. +// +// By default, the Aggregate condition has the same type of the source condition, but this can be changed by using +// the TargetConditionType option. +// +// Additionally, it is possible to inject custom merge strategies using the CustomMergeStrategy option. +func NewAggregateCondition[T Getter](sourceObjs []T, sourceConditionType string, opts ...AggregateOption) (*metav1.Condition, error) { + if len(sourceObjs) == 0 { + return nil, errors.New("sourceObjs can't be empty") + } + + aggregateOpt := &AggregateOptions{ + targetConditionType: sourceConditionType, + } + aggregateOpt.ApplyOptions(opts) + + if len(aggregateOpt.negativePolarityConditionTypes) != 0 && (aggregateOpt.negativePolarityConditionTypes[0] != sourceConditionType || len(aggregateOpt.negativePolarityConditionTypes) > 1) { + return nil, errors.Errorf("negativePolarityConditionTypes can only be set to [%q]", sourceConditionType) + } + + if aggregateOpt.mergeStrategy == nil { + // Note: If mergeStrategy is not explicitly set, target condition has negative polarity if source condition has negative polarity + targetConditionHasPositivePolarity := !sets.New[string](aggregateOpt.negativePolarityConditionTypes...).Has(sourceConditionType) + aggregateOpt.mergeStrategy = DefaultMergeStrategy(TargetConditionHasPositivePolarity(targetConditionHasPositivePolarity), GetPriorityFunc(GetDefaultMergePriorityFunc(aggregateOpt.negativePolarityConditionTypes...))) + } + + conditionsInScope := make([]ConditionWithOwnerInfo, 0, len(sourceObjs)) + for _, obj := range sourceObjs { + conditions := getConditionsWithOwnerInfo(obj) + + // Drops all the conditions not in scope for the merge operation + hasConditionType := false + for _, condition := range conditions { + if condition.Type != sourceConditionType { + continue + } + conditionsInScope = append(conditionsInScope, condition) + hasConditionType = true + break + } + + // Add the expected conditions if it does not exist, so we are compliant with K8s guidelines + // (all missing conditions should be considered unknown). + if !hasConditionType { + conditionOwner := getConditionOwnerInfo(obj) + + conditionsInScope = append(conditionsInScope, ConditionWithOwnerInfo{ + OwnerResource: conditionOwner, + Condition: metav1.Condition{ + Type: sourceConditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported", sourceConditionType), + // NOTE: LastTransitionTime and ObservedGeneration are not relevant for merge. + }, + }) + } + } + + status, reason, message, err := aggregateOpt.mergeStrategy.Merge(AggregateMergeOperation, conditionsInScope, []string{sourceConditionType}) + if err != nil { + return nil, err + } + + c := &metav1.Condition{ + Type: aggregateOpt.targetConditionType, + Status: status, + Reason: reason, + Message: message, + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + } + + return c, err +} + +// SetAggregateCondition is a convenience method that calls NewAggregateCondition to create an aggregate condition from the source objects, +// and then calls Set to add the new condition to the target object. +func SetAggregateCondition[T Getter](sourceObjs []T, targetObj Setter, conditionType string, opts ...AggregateOption) error { + aggregateCondition, err := NewAggregateCondition(sourceObjs, conditionType, opts...) + if err != nil { + return err + } + Set(targetObj, *aggregateCondition) + return nil +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/aggregate_test.go b/util/deprecated/v1beta1/conditions/v1beta2/aggregate_test.go new file mode 100644 index 000000000000..2ca2ac281605 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/aggregate_test.go @@ -0,0 +1,457 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestAggregate(t *testing.T) { + tests := []struct { + name string + conditions [][]metav1.Condition + conditionType string + options []AggregateOption + want *metav1.Condition + wantErr bool + }{ + { + name: "One issue", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "One issue with negative polarity", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.ScalingUpV1Beta2Condition, + options: []AggregateOption{NegativePolarityConditionTypes{clusterv1.ScalingUpV1Beta2Condition}}, + want: &metav1.Condition{ + Type: clusterv1.ScalingUpV1Beta2Condition, + Status: metav1.ConditionTrue, // True because there is one issue, and the target condition has negative polarity + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Error if negative polarity conditions are misconfigured", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, + }, + conditionType: clusterv1.ScalingUpV1Beta2Condition, + options: []AggregateOption{NegativePolarityConditionTypes{"foo"}}, // NegativePolarityConditionTypes if set must equal source condition + want: nil, + wantErr: true, + }, + { + name: "One issue with custom merge strategy", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.ScalingUpV1Beta2Condition, + options: []AggregateOption{NegativePolarityConditionTypes{clusterv1.ScalingUpV1Beta2Condition}, CustomMergeStrategy{ + MergeStrategy: DefaultMergeStrategy( + TargetConditionHasPositivePolarity(true), + GetPriorityFunc(GetDefaultMergePriorityFunc(clusterv1.ScalingUpV1Beta2Condition)), + ComputeReasonFunc(GetDefaultComputeMergeReasonFunc( + "bad", + "unknown", + "good", + )), + ), + }}, + want: &metav1.Condition{ + Type: clusterv1.ScalingUpV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue, and the custom merge strategy doesn't set the flag that defines that the target condition has negative polarity + Reason: "bad", // Using reason from the ComputeReasonFunc + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "One issue with custom merge strategy (negative polarity)", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.ScalingUpV1Beta2Condition, + options: []AggregateOption{NegativePolarityConditionTypes{clusterv1.ScalingUpV1Beta2Condition}, CustomMergeStrategy{ + MergeStrategy: DefaultMergeStrategy( + TargetConditionHasPositivePolarity(false), + GetPriorityFunc(GetDefaultMergePriorityFunc(clusterv1.ScalingUpV1Beta2Condition)), + ComputeReasonFunc(GetDefaultComputeMergeReasonFunc( + "good", // Note: with negative polarity, false is good + "unknown", + "bad", + )), + ), + }}, + want: &metav1.Condition{ + Type: clusterv1.ScalingUpV1Beta2Condition, + Status: metav1.ConditionTrue, // True because there is one issue, and the custom merge strategy sets the flag that defines that the target condition has negative polarity + Reason: "good", // Using reason from the ComputeReasonFunc + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "One issue with target type", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{TargetConditionType("SomethingAvailable")}, + want: &metav1.Condition{ + Type: "SomethingAvailable", + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "One issue with target type and negative polarity", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.ScalingUpV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: clusterv1.ScalingUpV1Beta2Condition, + options: []AggregateOption{TargetConditionType("SomethingAvailable"), NegativePolarityConditionTypes{clusterv1.ScalingUpV1Beta2Condition}}, + want: &metav1.Condition{ + Type: "SomethingAvailable", + Status: metav1.ConditionTrue, // True because there is one issue, and the target condition has negative polarity + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Same issue from up to three objects", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj3 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj1, obj2: Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Same issue from more than three objects", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Message: "Message-99"}}, // obj5 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj1, obj2, ... (2 more): Message-1", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Up to three different issue messages", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj3, obj4: Message-1\n" + + "* Phase3Objs obj1, obj2: Message-2\n" + + "* Phase3Obj obj5: Message-3", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Up to three different issue messages; if message is a list, it should be indented", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "* Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "* Message-2A\n* Message-2B"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "* Message-2A\n* Message-2B"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "* Message-1"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "* Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "* Message-3"}}, // obj5 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj3, obj4:\n" + + " * Message-1\n" + + "* Phase3Objs obj1, obj2:\n" + + " * Message-2A\n" + + " * Message-2B\n" + + "* Phase3Obj obj5:\n" + + " * Message-3", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "More than three different issue messages", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-5", Message: "Message-5"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj4: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj2: Message-4\n" + + "And 2 Phase3Objs with other issues", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Less than 2 issue messages and unknown message", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj3 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj2: Message-3", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "At least 3 issue messages and unknown message", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj4 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj3: Message-4\n" + + "And 1 Phase3Obj with status unknown", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "More than 3 issue messages and unknown message", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-5", Message: "Message-5"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-6", Message: "Message-6"}}, // obj5 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj3: Message-4\n" + + "And 2 Phase3Objs with other issues\n" + + "And 1 Phase3Obj with status unknown", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "unknown messages", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-5", Message: "Message-5"}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionUnknown, // Unknown because there is at least an unknown and no issue + Reason: unknownReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj4: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj2: Message-4\n" + + "And 2 Phase3Objs with status unknown", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "info messages", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-5", Message: ""}}, // obj3 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionTrue, // True because there are no issue and unknown + Reason: infoReportedReason, // Using a generic reason + Message: "* Phase3Objs obj0, obj4: Message-1\n" + + "* Phase3Obj obj1: Message-2\n" + + "* Phase3Obj obj2: Message-4\n" + + "And 1 Phase3Obj with additional info", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Missing conditions are defaulted", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {}, // obj2 without available condition + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1\n" + + "* Phase3Obj obj1: Condition Available not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + { + name: "Missing conditions are defaulted why a custom target condition type", + conditions: [][]metav1.Condition{ + {{Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {}, // obj2 without available condition + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []AggregateOption{TargetConditionType("SomethingAvailable")}, + want: &metav1.Condition{ + Type: "SomethingAvailable", + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* Phase3Obj obj0: Message-1\n" + + "* Phase3Obj obj1: Condition Available not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + objs := make([]Getter, 0, len(tt.conditions)) + for i := range tt.conditions { + objs = append(objs, &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: fmt.Sprintf("obj%d", i), + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions[i], + }, + }) + } + + got, err := NewAggregateCondition(objs, tt.conditionType, tt.options...) + g.Expect(err != nil).To(Equal(tt.wantErr)) + + g.Expect(got).To(Equal(tt.want)) + }) + } + + t.Run("Fails if source objects are empty", func(t *testing.T) { + var objs []*builder.Phase3Obj + g := NewWithT(t) + _, err := NewAggregateCondition(objs, clusterv1.AvailableV1Beta2Condition) + g.Expect(err).To(HaveOccurred()) + }) +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/doc.go b/util/deprecated/v1beta1/conditions/v1beta2/doc.go new file mode 100644 index 000000000000..7826b50ba77b --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/doc.go @@ -0,0 +1,34 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1beta2 implements utils for metav1.Conditions that will be used starting with the v1beta2 API. +// +// Please note that in order to make this change while respecting API deprecation rules, it is required +// to go through a phased approach: +// - Phase 1. metav1.Conditions will be added into v1beta1 API types under the Status.V1Beta2.Conditions struct (clusterv1.Conditions will remain in Status.Conditions) +// - Phase 2. when introducing v1beta2 API types: +// - clusterv1.Conditions will be moved from Status.Conditions to Status.Deprecated.V1Beta1.Conditions +// - metav1.Conditions will be moved from Status.V1Beta2.Conditions to Status.Conditions +// +// - Phase 3. when removing v1beta1 API types, Status.Deprecated will be dropped. +// +// Please see the proposal https://github.com/kubernetes-sigs/cluster-api/tree/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details. +// +// In order to make this transition easier both for CAPI and other projects using this package, +// utils automatically adapt to handle objects at different stage of the transition. +// +// Deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details. +package v1beta2 diff --git a/util/deprecated/v1beta1/conditions/v1beta2/getter.go b/util/deprecated/v1beta1/conditions/v1beta2/getter.go new file mode 100644 index 000000000000..f76b9c0880e1 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/getter.go @@ -0,0 +1,236 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/cluster-api/util" +) + +// TODO: Move to the API package. +const ( + // NoReasonReported identifies a clusterv1.Condition that reports no reason. + NoReasonReported = "NoReasonReported" +) + +// Getter interface defines methods that an API object should implement in order to +// use the conditions package for getting conditions. +type Getter interface { + // GetV1Beta2Conditions returns the list of conditions for a cluster API object. + // Note: GetV1Beta2Conditions will be renamed to GetConditions in a later stage of the transition to V1Beta2. + GetV1Beta2Conditions() []metav1.Condition +} + +// Get returns a condition from the object implementing the Getter interface. +// +// Please note that Get does not support reading conditions from unstructured objects nor from API types not implementing +// the Getter interface. Eventually, users can implement wrappers on those types implementing this interface and +// taking care of aligning the condition format if necessary. +func Get(sourceObj Getter, sourceConditionType string) *metav1.Condition { + // if obj is nil, the requested condition does not exist. + if util.IsNil(sourceObj) { + return nil + } + + // Otherwise get the requested condition. + return meta.FindStatusCondition(sourceObj.GetV1Beta2Conditions(), sourceConditionType) +} + +// Has returns true if a condition with the given type exists. +func Has(from Getter, conditionType string) bool { + return Get(from, conditionType) != nil +} + +// IsTrue is true if the condition with the given type is True, otherwise it returns false +// if the condition is not True or if the condition does not exist (is nil). +func IsTrue(from Getter, conditionType string) bool { + if c := Get(from, conditionType); c != nil { + return c.Status == metav1.ConditionTrue + } + return false +} + +// IsFalse is true if the condition with the given type is False, otherwise it returns false +// if the condition is not False or if the condition does not exist (is nil). +func IsFalse(from Getter, conditionType string) bool { + if c := Get(from, conditionType); 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, conditionType string) bool { + if c := Get(from, conditionType); c != nil { + return c.Status == metav1.ConditionUnknown + } + return true +} + +// UnstructuredGetAll returns conditions from an Unstructured object. +// +// UnstructuredGetAll supports retrieving conditions from objects at different stages of the transition from +// clusterv1.conditions to the metav1.Condition type: +// - Objects with clusterv1.Conditions in status.conditions; in this case a best effort conversion +// to metav1.Condition is performed, just enough to allow surfacing a condition from a provider object with Mirror +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +func UnstructuredGetAll(sourceObj runtime.Unstructured) ([]metav1.Condition, error) { + if util.IsNil(sourceObj) { + return nil, errors.New("sourceObj is nil") + } + + ownerInfo := getConditionOwnerInfo(sourceObj) + + value, exists, err := unstructured.NestedFieldNoCopy(sourceObj.UnstructuredContent(), "status", "v1beta2", "conditions") + if exists && err == nil { + if conditions, ok := value.([]interface{}); ok { + r, err := convertFromUnstructuredConditions(conditions) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert status.v1beta2.conditions from %s to []metav1.Condition", ownerInfo.Kind) + } + return r, nil + } + } + + value, exists, err = unstructured.NestedFieldNoCopy(sourceObj.UnstructuredContent(), "status", "conditions") + if exists && err == nil { + if conditions, ok := value.([]interface{}); ok { + r, err := convertFromUnstructuredConditions(conditions) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert status.conditions from %s to []metav1.Condition", ownerInfo.Kind) + } + return r, nil + } + } + + // With unstructured, it is not possible to detect if conditions are not set if the type is wrongly defined. + // This methods assume condition are not set. + return nil, nil +} + +// UnstructuredGet returns a condition from an Unstructured object. +// +// UnstructuredGet supports retrieving conditions from objects at different stages of the transition from +// clusterv1.conditions to the metav1.Condition type: +// - Objects with clusterv1.Conditions in status.conditions; in this case a best effort conversion +// to metav1.Condition is performed, just enough to allow surfacing a condition from a provider object with Mirror +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +func UnstructuredGet(sourceObj runtime.Unstructured, sourceConditionType string) (*metav1.Condition, error) { + r, err := UnstructuredGetAll(sourceObj) + if err != nil { + return nil, err + } + return meta.FindStatusCondition(r, sourceConditionType), nil +} + +// convertFromUnstructuredConditions converts []interface{} to []metav1.Condition; this operation must account for +// objects which are not transitioning to metav1.Condition, or not yet fully transitioned, and thus a best +// effort conversion of values to metav1.Condition is performed. +func convertFromUnstructuredConditions(conditions []interface{}) ([]metav1.Condition, error) { + if conditions == nil { + return nil, nil + } + + convertedConditions := make([]metav1.Condition, 0, len(conditions)) + for _, c := range conditions { + cMap, ok := c.(map[string]interface{}) + if !ok || cMap == nil { + continue + } + + var conditionType string + if v, ok := cMap["type"]; ok { + conditionType = v.(string) + } + + var status string + if v, ok := cMap["status"]; ok { + status = v.(string) + } + + var observedGeneration int64 + if v, ok := cMap["observedGeneration"]; ok { + observedGeneration = v.(int64) + } + + var lastTransitionTime metav1.Time + if v, ok := cMap["lastTransitionTime"]; ok && v != nil && v.(string) != "" { + if err := lastTransitionTime.UnmarshalQueryParameter(v.(string)); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal lastTransitionTime value: %s", v) + } + } + + var reason string + if v, ok := cMap["reason"]; ok { + reason = v.(string) + } + + var message string + if v, ok := cMap["message"]; ok { + message = v.(string) + } + + c := metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionStatus(status), + ObservedGeneration: observedGeneration, + LastTransitionTime: lastTransitionTime, + Reason: reason, + Message: message, + } + if err := validateAndFixConvertedCondition(&c); err != nil { + return nil, err + } + + convertedConditions = append(convertedConditions, c) + } + return convertedConditions, nil +} + +// validateAndFixConvertedCondition validates and fixes a clusterv1.Condition converted to a metav1.Condition. +// this operation assumes conditions have been set using Cluster API condition utils; +// also, only a few, minimal rules are enforced, just enough to allow surfacing a condition from a providers object with Mirror. +func validateAndFixConvertedCondition(c *metav1.Condition) error { + if c.Type == "" { + return errors.New("type must be set for all conditions") + } + if c.Status == "" { + return errors.Errorf("status must be set for the %s condition", c.Type) + } + switch c.Status { + case metav1.ConditionFalse, metav1.ConditionTrue, metav1.ConditionUnknown: + break + default: + return errors.Errorf("status for the %s condition must be one of %s, %s, %s", c.Type, metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown) + } + if c.Reason == "" { + c.Reason = NoReasonReported + } + + // NOTE: Empty LastTransitionTime is tolerated because it will be set when assigning the newly generated mirror condition to an object. + // NOTE: Other metav1.Condition validations rules, e.g. regex, are not enforced at this stage; they will be enforced by the API server at a later stage. + + return nil +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/getter_test.go b/util/deprecated/v1beta1/conditions/v1beta2/getter_test.go new file mode 100644 index 000000000000..56f13c0c09b5 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/getter_test.go @@ -0,0 +1,646 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "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" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestGet(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + t.Run("handles nil", func(t *testing.T) { + g := NewWithT(t) + + got := Get(nil, "bar") + g.Expect(got).To(BeNil()) + }) + + t.Run("handles pointer to nil object", func(t *testing.T) { + g := NewWithT(t) + var foo *builder.Phase1Obj + + got := Get(foo, "bar") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase1 object with both legacy and v1beta2 conditions (v1beta2 nil)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: nil, + }, + } + + got := Get(foo, "barCondition") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase1 object with both legacy and v1beta2 conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: &builder.Phase1ObjV1Beta2Status{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 10, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.V1Beta2.Conditions[0].Type, + Status: foo.Status.V1Beta2.Conditions[0].Status, + LastTransitionTime: foo.Status.V1Beta2.Conditions[0].LastTransitionTime, + ObservedGeneration: foo.Status.V1Beta2.Conditions[0].ObservedGeneration, + Reason: foo.Status.V1Beta2.Conditions[0].Reason, + Message: foo.Status.V1Beta2.Conditions[0].Message, + } + + got := Get(foo, "barCondition") + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) + + t.Run("Phase2 object with conditions (nil) and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: nil, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + got := Get(foo, "barCondition") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase2 object with conditions (empty) and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{}, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + got := Get(foo, "barCondition") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + } + + got := Get(foo, "barCondition") + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) + + t.Run("Phase3 object with conditions (nil)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: nil, + }, + } + got := Get(foo, "barCondition") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase3 object with conditions (empty)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{}, + }, + } + + got := Get(foo, "barCondition") + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase3 object with conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + } + + got := Get(foo, "barCondition") + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) + + t.Run("handles objects with value getter", func(t *testing.T) { + g := NewWithT(t) + foo := &objectWithValueGetter{ + Status: objectWithValueGetterStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + } + + got := Get(foo, "barCondition") + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) +} + +func TestUnstructuredGet(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + t.Run("handles nil", func(t *testing.T) { + g := NewWithT(t) + + _, err := UnstructuredGet(nil, "bar") + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("handles pointer to nil object", func(t *testing.T) { + g := NewWithT(t) + var foo runtime.Unstructured + + _, err := UnstructuredGet(foo, "bar") + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("Phase1 object with both legacy and v1beta2 conditions (v1beta2 nil)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: nil, + }, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase1 object with both legacy and v1beta2 conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: &builder.Phase1ObjV1Beta2Status{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 10, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.V1Beta2.Conditions[0].Type, + Status: foo.Status.V1Beta2.Conditions[0].Status, + LastTransitionTime: foo.Status.V1Beta2.Conditions[0].LastTransitionTime, + ObservedGeneration: foo.Status.V1Beta2.Conditions[0].ObservedGeneration, + Reason: foo.Status.V1Beta2.Conditions[0].Reason, + Message: foo.Status.V1Beta2.Conditions[0].Message, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) + + t.Run("Phase2 object with conditions (nil) and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: nil, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase2 object with conditions (empty) and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{}, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) + + t.Run("Phase3 object with conditions (nil)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: nil, + }, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase3 object with conditions (empty)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{}, + }, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("Phase3 object with conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + }, + } + + expect := metav1.Condition{ + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + } + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := UnstructuredGet(&unstructured.Unstructured{Object: fooUnstructured}, "barCondition") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(MatchCondition(expect), cmp.Diff(*got, expect)) + }) +} + +func TestConvertFromUnstructuredConditions(t *testing.T) { + tests := []struct { + name string + conditions []clusterv1.Condition + want []metav1.Condition + wantError bool + }{ + { + name: "Fails if Type is missing", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Status: corev1.ConditionTrue}, + }, + wantError: true, + }, + { + name: "Fails if Status is missing", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo")}, + }, + wantError: true, + }, + { + name: "Fails if Status is a wrong value", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo"), Status: "foo"}, + }, + wantError: true, + }, + { + name: "Defaults reason for positive polarity", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo"), Status: corev1.ConditionTrue}, + }, + wantError: false, + want: []metav1.Condition{ + { + Type: "foo", + Status: metav1.ConditionTrue, + Reason: NoReasonReported, + }, + }, + }, + { + name: "Defaults reason for negative polarity", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo"), Status: corev1.ConditionFalse}, + }, + wantError: false, + want: []metav1.Condition{ + { + Type: "foo", + Status: metav1.ConditionFalse, + Reason: NoReasonReported, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&builder.Phase0Obj{Status: builder.Phase0ObjStatus{Conditions: tt.conditions}}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(unstructuredObj).To(HaveKey("status")) + unstructuredStatusObj := unstructuredObj["status"].(map[string]interface{}) + g.Expect(unstructuredStatusObj).To(HaveKey("conditions")) + + got, err := convertFromUnstructuredConditions(unstructuredStatusObj["conditions"].([]interface{})) + if tt.wantError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want), cmp.Diff(tt.want, got)) + }) + } +} + +func TestIsMethods(t *testing.T) { + g := NewWithT(t) + + obj := objectWithValueGetter{ + Status: objectWithValueGetterStatus{ + Conditions: []metav1.Condition{ + {Type: "trueCondition", Status: metav1.ConditionTrue}, + {Type: "falseCondition", Status: metav1.ConditionFalse}, + {Type: "unknownCondition", Status: metav1.ConditionUnknown}, + }, + }, + } + + // test isTrue + g.Expect(IsTrue(obj, "trueCondition")).To(BeTrue()) + g.Expect(IsTrue(obj, "falseCondition")).To(BeFalse()) + g.Expect(IsTrue(obj, "unknownCondition")).To(BeFalse()) + // test isFalse + g.Expect(IsFalse(obj, "trueCondition")).To(BeFalse()) + g.Expect(IsFalse(obj, "falseCondition")).To(BeTrue()) + g.Expect(IsFalse(obj, "unknownCondition")).To(BeFalse()) + + // test isUnknown + g.Expect(IsUnknown(obj, "trueCondition")).To(BeFalse()) + g.Expect(IsUnknown(obj, "falseCondition")).To(BeFalse()) + g.Expect(IsUnknown(obj, "unknownCondition")).To(BeTrue()) +} + +type objectWithValueGetter struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec objectWithValueGetterSpec `json:"spec,omitempty"` + Status objectWithValueGetterStatus `json:"status,omitempty"` +} + +type objectWithValueGetterSpec struct { + Foo string `json:"foo,omitempty"` +} + +type objectWithValueGetterStatus struct { + Bar string `json:"bar,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +func (o objectWithValueGetter) GetV1Beta2Conditions() []metav1.Condition { + return o.Status.Conditions +} + +func (o *objectWithValueGetter) SetV1Beta2Conditions(conditions []metav1.Condition) { + o.Status.Conditions = conditions +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/matcher.go b/util/deprecated/v1beta1/conditions/v1beta2/matcher.go new file mode 100644 index 000000000000..d842b2136e19 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/matcher.go @@ -0,0 +1,146 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "fmt" + + "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// IgnoreLastTransitionTime instructs MatchConditions and MatchCondition to ignore the LastTransitionTime field. +type IgnoreLastTransitionTime bool + +// ApplyMatch applies this configuration to the given Match options. +func (f IgnoreLastTransitionTime) ApplyMatch(opts *MatchOptions) { + opts.ignoreLastTransitionTime = bool(f) +} + +// MatchOption is some configuration that modifies options for a match call. +type MatchOption interface { + // ApplyMatch applies this configuration to the given match options. + ApplyMatch(option *MatchOptions) +} + +// MatchOptions allows to set options for the match operation. +type MatchOptions struct { + ignoreLastTransitionTime bool +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *MatchOptions) ApplyOptions(opts []MatchOption) *MatchOptions { + for _, opt := range opts { + opt.ApplyMatch(o) + } + return o +} + +// MatchConditions returns a custom matcher to check equality of []metav1.Condition. +func MatchConditions(expected []metav1.Condition, opts ...MatchOption) types.GomegaMatcher { + return &matchConditions{ + opts: opts, + expected: expected, + } +} + +type matchConditions struct { + opts []MatchOption + 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, m.opts...)) + } + + return gomega.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, opts ...MatchOption) types.GomegaMatcher { + return &matchCondition{ + opts: opts, + expected: expected, + } +} + +type matchCondition struct { + opts []MatchOption + expected metav1.Condition +} + +func (m matchCondition) Match(actual interface{}) (success bool, err error) { + matchOpt := &MatchOptions{ + ignoreLastTransitionTime: false, + } + matchOpt.ApplyOptions(m.opts) + + actualCondition, ok := actual.(metav1.Condition) + if !ok { + return false, fmt.Errorf("actual should be of type metav1.Condition") + } + + ok, err = gomega.Equal(m.expected.Type).Match(actualCondition.Type) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Status).Match(actualCondition.Status) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.ObservedGeneration).Match(actualCondition.ObservedGeneration) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Reason).Match(actualCondition.Reason) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Message).Match(actualCondition.Message) + if !ok { + return ok, err + } + + if !matchOpt.ignoreLastTransitionTime { + ok, err = gomega.BeTemporally("==", m.expected.LastTransitionTime.Time).Match(actualCondition.LastTransitionTime.Time) + 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/util/deprecated/v1beta1/conditions/v1beta2/matcher_test.go b/util/deprecated/v1beta1/conditions/v1beta2/matcher_test.go new file mode 100644 index 000000000000..d51496ae9300 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/matcher_test.go @@ -0,0 +1,327 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMatchConditions(t *testing.T) { + t0 := metav1.Now() + + 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: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expectMatch: true, + }, + { + name: "with non-matching conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "different", + Message: "different", + }, + }, + expectMatch: false, + }, + { + name: "with a different number of conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + 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) { + t0 := metav1.Now() + t1 := metav1.NewTime(t0.Add(1 * -time.Minute)) + + testCases := []struct { + name string + actual interface{} + expected metav1.Condition + options []MatchOption + expectMatch bool + }{ + { + name: "with an empty condition", + actual: metav1.Condition{}, + expected: metav1.Condition{}, + options: []MatchOption{}, + expectMatch: true, + }, + { + name: "with a matching condition", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: true, + }, + { + name: "with a different LastTransitionTime", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t1, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different LastTransitionTime but with IgnoreLastTransitionTime", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t1, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{IgnoreLastTransitionTime(true)}, + expectMatch: true, + }, + { + name: "with a different type", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different status", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionFalse, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different ObservedGeneration", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + ObservedGeneration: 2, + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different reason", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "different", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different message", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "different", + }, + options: []MatchOption{}, + 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(MatchCondition(tc.expected, tc.options...)) + } else { + g.Expect(tc.actual).ToNot(MatchCondition(tc.expected, tc.options...)) + } + }) + } +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies.go b/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies.go new file mode 100644 index 000000000000..1d45e6c271ca --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies.go @@ -0,0 +1,650 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/gobuffalo/flect" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// ConditionWithOwnerInfo is a wrapper around metav1.Condition with additional ConditionOwnerInfo. +// These infos can be used when generating the message resulting from the merge operation. +type ConditionWithOwnerInfo struct { + OwnerResource ConditionOwnerInfo + metav1.Condition +} + +// ConditionOwnerInfo contains infos about the object that owns the condition. +type ConditionOwnerInfo struct { + Kind string + Name string + IsControlPlaneMachine bool +} + +// String returns a string representation of the ConditionOwnerInfo. +func (o ConditionOwnerInfo) String() string { + return fmt.Sprintf("%s %s", o.Kind, o.Name) +} + +// MergeOperation defines merge operations. +type MergeOperation string + +const ( + // SummaryMergeOperation defines a merge operation of type Summary. + // Summary should merge different conditions from the same object. + SummaryMergeOperation MergeOperation = "Summary" + + // AggregateMergeOperation defines a merge operation of type Aggregate. + // Aggregate should merge the same condition across many objects. + AggregateMergeOperation MergeOperation = "Aggregate" +) + +// MergeStrategy defines a strategy used to merge conditions during the aggregate or summary operation. +type MergeStrategy interface { + // Merge passed in conditions. + // + // It is up to the caller to ensure that all the expected conditions exist (e.g. by adding new conditions with status Unknown). + // Conditions passed in must be of the given conditionTypes (other condition types must be discarded). + // + // The list of conditionTypes has an implicit order; it is up to the implementation of merge to use this info or not. + Merge(operation MergeOperation, conditions []ConditionWithOwnerInfo, conditionTypes []string) (status metav1.ConditionStatus, reason, message string, err error) +} + +// DefaultMergeStrategyOption is some configuration that modifies the DefaultMergeStrategy behaviour. +type DefaultMergeStrategyOption interface { + // ApplyToDefaultMergeStrategy applies this configuration to the given DefaultMergeStrategy options. + ApplyToDefaultMergeStrategy(option *DefaultMergeStrategyOptions) +} + +// DefaultMergeStrategyOptions allows to set options for the DefaultMergeStrategy behaviour. +type DefaultMergeStrategyOptions struct { + getPriorityFunc func(condition metav1.Condition) MergePriority + targetConditionHasPositivePolarity bool + computeReasonFunc func(issueConditions []ConditionWithOwnerInfo, unknownConditions []ConditionWithOwnerInfo, infoConditions []ConditionWithOwnerInfo) string + summaryMessageTransformFunc func([]string) []string +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *DefaultMergeStrategyOptions) ApplyOptions(opts []DefaultMergeStrategyOption) *DefaultMergeStrategyOptions { + for _, opt := range opts { + opt.ApplyToDefaultMergeStrategy(o) + } + return o +} + +// DefaultMergeStrategy returns the default merge strategy. +// +// Use the GetPriorityFunc option to customize how the MergePriority for a given condition is computed. +// If not specified, conditions are considered issues or not if not to their normal state given the polarity +// (e.g. a positive polarity condition is considered to be reporting an issue when status is false, +// otherwise the condition is considered to be reporting an info unless status is unknown). +// +// Use the TargetConditionHasPositivePolarity to define the polarity of the condition returned by the DefaultMergeStrategy. +// If not specified, the generate condition will have positive polarity (status true = good). +// +// Use the ComputeReasonFunc to customize how the reason for the resulting condition will be computed. +// If not specified, generic reasons will be used. +func DefaultMergeStrategy(opts ...DefaultMergeStrategyOption) MergeStrategy { + strategyOpt := &DefaultMergeStrategyOptions{ + targetConditionHasPositivePolarity: true, + computeReasonFunc: GetDefaultComputeMergeReasonFunc(issuesReportedReason, unknownReportedReason, infoReportedReason), // NOTE: when no specific reason are provided, generic ones are used. + getPriorityFunc: GetDefaultMergePriorityFunc(), + summaryMessageTransformFunc: nil, + } + strategyOpt.ApplyOptions(opts) + + return &defaultMergeStrategy{ + getPriorityFunc: strategyOpt.getPriorityFunc, + computeReasonFunc: strategyOpt.computeReasonFunc, + targetConditionHasPositivePolarity: strategyOpt.targetConditionHasPositivePolarity, + summaryMessageTransformFunc: strategyOpt.summaryMessageTransformFunc, + } +} + +// GetDefaultMergePriorityFunc returns the merge priority for each condition. +// It assigns following priority values to conditions: +// - issues: conditions with positive polarity (normal True) and status False or conditions with negative polarity (normal False) and status True. +// - unknown: conditions with status unknown. +// - info: conditions with positive polarity (normal True) and status True or conditions with negative polarity (normal False) and status False. +func GetDefaultMergePriorityFunc(negativePolarityConditionTypes ...string) func(condition metav1.Condition) MergePriority { + negativePolarityConditionTypesSet := sets.New[string](negativePolarityConditionTypes...) + return func(condition metav1.Condition) MergePriority { + switch condition.Status { + case metav1.ConditionTrue: + if negativePolarityConditionTypesSet.Has(condition.Type) { + return IssueMergePriority + } + return InfoMergePriority + case metav1.ConditionFalse: + if negativePolarityConditionTypesSet.Has(condition.Type) { + return InfoMergePriority + } + return IssueMergePriority + case metav1.ConditionUnknown: + return UnknownMergePriority + } + + // Note: this should never happen. In case, those conditions are considered like conditions with unknown status. + return UnknownMergePriority + } +} + +// MergePriority defines the priority for a condition during a merge operation. +type MergePriority uint8 + +const ( + // IssueMergePriority is the merge priority used by GetDefaultMergePriority in case the condition state is considered an issue. + IssueMergePriority MergePriority = iota + + // UnknownMergePriority is the merge priority used by GetDefaultMergePriority in case of unknown conditions. + UnknownMergePriority + + // InfoMergePriority is the merge priority used by GetDefaultMergePriority in case the condition state is not considered an issue. + InfoMergePriority +) + +// GetDefaultComputeMergeReasonFunc return a function picking one of the three reasons in input depending on +// the status of the conditions being merged. +func GetDefaultComputeMergeReasonFunc(issueReason, unknownReason, infoReason string) func(issueConditions []ConditionWithOwnerInfo, unknownConditions []ConditionWithOwnerInfo, infoConditions []ConditionWithOwnerInfo) string { + return func(issueConditions []ConditionWithOwnerInfo, unknownConditions []ConditionWithOwnerInfo, _ []ConditionWithOwnerInfo) string { + switch { + case len(issueConditions) > 0: + return issueReason + case len(unknownConditions) > 0: + return unknownReason + default: + // Note: This func can assume that there is at least one condition, so this branch is equivalent to len(infoReason) > 0, + // and it makes the linter happy. + return infoReason + } + } +} + +const ( + // issuesReportedReason is set on conditions generated during aggregate or summary operations when at least one conditions/objects are reporting issues. + // NOTE: This const is used by GetDefaultComputeMergeReasonFunc if no specific reasons are provided. + issuesReportedReason = "IssuesReported" + + // unknownReportedReason is set on conditions generated during aggregate or summary operations when at least one conditions/objects are reporting unknown. + // NOTE: This const is used by GetDefaultComputeMergeReasonFunc if no specific reasons are provided. + unknownReportedReason = "UnknownReported" + + // infoReportedReason is set on conditions generated during aggregate or summary operations when at least one conditions/objects are reporting info. + // NOTE: This const is used by GetDefaultComputeMergeReasonFunc if no specific reasons are provided. + infoReportedReason = "InfoReported" +) + +// defaultMergeStrategy defines the default merge strategy for Cluster API conditions. +type defaultMergeStrategy struct { + getPriorityFunc func(condition metav1.Condition) MergePriority + targetConditionHasPositivePolarity bool + computeReasonFunc func(issueConditions []ConditionWithOwnerInfo, unknownConditions []ConditionWithOwnerInfo, infoConditions []ConditionWithOwnerInfo) string + summaryMessageTransformFunc func([]string) []string +} + +// Merge all conditions in input based on a strategy that surfaces issues first, then unknown conditions, then info (if none of issues and unknown condition exists). +// - issues: conditions with positive polarity (normal True) and status False or conditions with negative polarity (normal False) and status True. +// - unknown: conditions with status unknown. +// - info: conditions with positive polarity (normal True) and status True or conditions with negative polarity (normal False) and status False. +func (d *defaultMergeStrategy) Merge(operation MergeOperation, conditions []ConditionWithOwnerInfo, conditionTypes []string) (status metav1.ConditionStatus, reason, message string, err error) { + if len(conditions) == 0 { + return "", "", "", errors.New("can't merge an empty list of conditions") + } + + if d.getPriorityFunc == nil { + return "", "", "", errors.New("can't merge without a getPriority func") + } + + // sortConditions the relevance defined by the users (the order of condition types), LastTransition time (older first). + sortConditions(conditions, conditionTypes) + + issueConditions, unknownConditions, infoConditions := splitConditionsByPriority(conditions, d.getPriorityFunc) + + // Compute the status for the target condition: + // Note: This function always returns a condition with positive polarity. + // - if there are issues, use false + // - else if there are unknown, use unknown + // - else if there are info, use true + switch { + case len(issueConditions) > 0: + if d.targetConditionHasPositivePolarity { + status = metav1.ConditionFalse + } else { + status = metav1.ConditionTrue + } + case len(unknownConditions) > 0: + status = metav1.ConditionUnknown + case len(infoConditions) > 0: + if d.targetConditionHasPositivePolarity { + status = metav1.ConditionTrue + } else { + status = metav1.ConditionFalse + } + default: + // NOTE: this is already handled above, but repeating also here for better readability. + return "", "", "", errors.New("can't merge an empty list of conditions") + } + + // Compute the reason for the target condition: + // - In case there is only one condition in the top group, use the reason from this condition + // - In case there are more than one condition in the top group, use a generic reason (for the target group) + reason = d.computeReasonFunc(issueConditions, unknownConditions, infoConditions) + + // Compute the message for the target condition, which is optimized for the operation being performed. + + // When performing the summary operation, usually we are merging a small set of conditions from the same object, + // Considering the small number of conditions, involved it is acceptable/preferred to provide as much detail + // as possible about the messages from the conditions being merged. + // + // Accordingly, the resulting message is composed by all the messages from conditions classified as issues/unknown; + // messages from conditions classified as info are included only if there are no issues/unknown. + // + // e.g. Condition-B (False): Message-B; Condition-!C (True): Message-!C; Condition-A (Unknown): Message-A + // + // When including messages from conditions, they are sorted by issue/unknown and by the implicit order of condition types + // provided by the user (it is considered as order of relevance). + if operation == SummaryMergeOperation { + message = summaryMessage(conditions, d, status) + } + + // When performing the aggregate operation, we are merging one single condition from potentially many objects. + // Considering the high number of conditions involved, the messages from the conditions being merged must be filtered/summarized + // using rules designed to surface the most important issues. + // + // Accordingly, the resulting message is composed by only three messages from conditions classified as issues/unknown; + // instead three messages from conditions classified as info are included only if there are no issues/unknown. + // + // Three criteria are used to pick the messages to be shown + // - Messages for control plane machines always go first + // - Messages for issues always go before messages for unknown, info messages goes last + // - The number of objects reporting the same message determine the order used to pick within the messages in the same bucket + // + // For each message it is reported a list of max 3 objects reporting the message; if more objects are reporting the same + // message, the number of those objects is surfaced. + // + // e.g. (False): Message-1 from obj0, obj1, obj2 and 2 more Objects + // + // If there are other objects - objects not included in the list above - reporting issues/unknown (or info there no issues/unknown), + // the number of those objects is surfaced. + // + // e.g. ...; 2 more Objects with issues; 1 more Objects with unknown status + // + if operation == AggregateMergeOperation { + n := 3 + messages := []string{} + + // Get max n issue/unknown messages, decrement n, and track if there are other objects reporting issues/unknown not included in the messages. + if len(issueConditions) > 0 || len(unknownConditions) > 0 { + issueMessages := aggregateMessages(append(issueConditions, unknownConditions...), &n, false, d.getPriorityFunc, map[MergePriority]string{IssueMergePriority: "with other issues", UnknownMergePriority: "with status unknown"}) + messages = append(messages, issueMessages...) + } + + // Only if there are no issue or unknown, + // Get max n info messages, decrement n, and track if there are other objects reporting info not included in the messages. + if len(issueConditions) == 0 && len(unknownConditions) == 0 && len(infoConditions) > 0 { + infoMessages := aggregateMessages(infoConditions, &n, true, d.getPriorityFunc, map[MergePriority]string{InfoMergePriority: "with additional info"}) + messages = append(messages, infoMessages...) + } + + message = strings.Join(messages, "\n") + } + + return status, reason, message, nil +} + +// sortConditions by condition types order, LastTransitionTime +// (the order of relevance defined by the users, the oldest first). +func sortConditions(conditions []ConditionWithOwnerInfo, orderedConditionTypes []string) { + conditionOrder := make(map[string]int, len(orderedConditionTypes)) + for i, conditionType := range orderedConditionTypes { + conditionOrder[conditionType] = i + } + + sort.SliceStable(conditions, func(i, j int) bool { + // Sort by condition order (user defined, useful when computing summary of different conditions from the same object) + return conditionOrder[conditions[i].Type] < conditionOrder[conditions[j].Type] || + // If same condition order, sort by last transition time (useful when computing aggregation of the same conditions from different objects) + (conditionOrder[conditions[i].Type] == conditionOrder[conditions[j].Type] && conditions[i].LastTransitionTime.Before(&conditions[j].LastTransitionTime)) + }) +} + +// splitConditionsByPriority split conditions in 3 groups: +// - conditions representing an issue. +// - conditions with status unknown. +// - conditions representing an info. +// NOTE: The order of conditions is preserved in each group. +func splitConditionsByPriority(conditions []ConditionWithOwnerInfo, getPriority func(condition metav1.Condition) MergePriority) (issueConditions, unknownConditions, infoConditions []ConditionWithOwnerInfo) { + for _, condition := range conditions { + switch getPriority(condition.Condition) { + case IssueMergePriority: + issueConditions = append(issueConditions, condition) + case UnknownMergePriority: + unknownConditions = append(unknownConditions, condition) + case InfoMergePriority: + infoConditions = append(infoConditions, condition) + } + } + return issueConditions, unknownConditions, infoConditions +} + +// summaryMessage returns message for the summary operation. +func summaryMessage(conditions []ConditionWithOwnerInfo, d *defaultMergeStrategy, status metav1.ConditionStatus) string { + messages := []string{} + + // Note: use conditions because we want to preserve the order of relevance defined by the users (the order of condition types). + for _, condition := range conditions { + priority := d.getPriorityFunc(condition.Condition) + if priority == InfoMergePriority { + // Drop info messages when we are surfacing issues or unknown. + if status != metav1.ConditionTrue { + continue + } + // Drop info conditions with empty messages. + if condition.Message == "" { + continue + } + } + + m := fmt.Sprintf("* %s:", condition.Type) + if condition.Message != "" { + m += indentIfMultiline(condition.Message) + } else { + m += fmt.Sprintf(" %s", condition.Reason) + } + messages = append(messages, m) + } + + if d.summaryMessageTransformFunc != nil { + messages = d.summaryMessageTransformFunc(messages) + } + + return strings.Join(messages, "\n") +} + +// aggregateMessages returns messages for the aggregate operation. +func aggregateMessages(conditions []ConditionWithOwnerInfo, n *int, dropEmpty bool, getPriority func(condition metav1.Condition) MergePriority, otherMessages map[MergePriority]string) (messages []string) { + // create a map with all the messages and the list of objects reporting the same message. + messageObjMap := map[string]map[string][]string{} + messagePriorityMap := map[string]MergePriority{} + messageMustGoFirst := map[string]bool{} + cpMachines := sets.Set[string]{} + for _, condition := range conditions { + if dropEmpty && condition.Message == "" { + continue + } + + // Keep track of the message and the list of objects it applies to. + m := condition.Message + if _, ok := messageObjMap[condition.OwnerResource.Kind]; !ok { + messageObjMap[condition.OwnerResource.Kind] = map[string][]string{} + } + messageObjMap[condition.OwnerResource.Kind][m] = append(messageObjMap[condition.OwnerResource.Kind][m], condition.OwnerResource.Name) + + // Keep track of CP machines + if condition.OwnerResource.IsControlPlaneMachine { + cpMachines.Insert(condition.OwnerResource.Name) + } + + // Keep track of the priority of the message. + // In case the same message exists with different priorities, the highest according to issue/unknown/info applies. + currentPriority, ok := messagePriorityMap[m] + newPriority := getPriority(condition.Condition) + switch { + case !ok: + messagePriorityMap[m] = newPriority + case currentPriority == IssueMergePriority: + // No-op, issue is already the highest priority. + case currentPriority == UnknownMergePriority: + // If current priority is unknown, use new one only if higher. + if newPriority == IssueMergePriority { + messagePriorityMap[m] = newPriority + } + case currentPriority == InfoMergePriority: + // if current priority is info, new one can be equal or higher, use it. + messagePriorityMap[m] = newPriority + } + + // Keep track if this message belongs to control plane machines, and thus it should go first. + // Note: it is enough that on object is a control plane machine to move the message as first. + first, ok := messageMustGoFirst[m] + if !ok || !first { + if condition.OwnerResource.IsControlPlaneMachine { + messageMustGoFirst[m] = true + } + } + } + + // Gets the objects kind (with a stable order). + kinds := make([]string, 0, len(messageObjMap)) + for kind := range messageObjMap { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + + // Aggregate messages for each object kind. + for _, kind := range kinds { + kindPlural := flect.Pluralize(kind) + messageObjMapForKind := messageObjMap[kind] + + // compute the order of messages according to: + // - message should go first (e.g. it applies to a control plane machine) + // - message priority (e.g. first issues, then unknown) + // - the number of objects reporting the same message. + // Note: The list of object names is used as a secondary criteria to sort messages with the same number of objects. + messageIndex := make([]string, 0, len(messageObjMapForKind)) + for m := range messageObjMapForKind { + messageIndex = append(messageIndex, m) + } + + sort.SliceStable(messageIndex, func(i, j int) bool { + return sortMessage(messageIndex[i], messageIndex[j], messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + }) + + // Pick the first n messages, decrement n. + // For each message, add up to three objects; if more add the number of the remaining objects with the same message. + // Count the number of objects reporting messages not included in the above. + // Note: we are showing up to three objects because usually control plane has 3 machines, and we want to show all issues + // to control plane machines if any, + others := map[MergePriority]int{} + for _, m := range messageIndex { + if *n == 0 { + others[messagePriorityMap[m]] += len(messageObjMapForKind[m]) + continue + } + + msg := "" + allObjects := messageObjMapForKind[m] + sort.Slice(allObjects, func(i, j int) bool { + return sortObj(allObjects[i], allObjects[j], cpMachines) + }) + switch { + case len(allObjects) == 0: + // This should never happen, entry in the map exists only when an object reports a message. + case len(allObjects) == 1: + msg += fmt.Sprintf("* %s %s:", kind, strings.Join(allObjects, ", ")) + case len(allObjects) <= 3: + msg += fmt.Sprintf("* %s %s:", kindPlural, strings.Join(allObjects, ", ")) + default: + msg += fmt.Sprintf("* %s %s, ... (%d more):", kindPlural, strings.Join(allObjects[:3], ", "), len(allObjects)-3) + } + msg += indentIfMultiline(m) + + messages = append(messages, msg) + *n-- + } + + for _, p := range []MergePriority{IssueMergePriority, UnknownMergePriority, InfoMergePriority} { + other, ok := others[p] + if !ok { + continue + } + + otherMessage, ok := otherMessages[p] + if !ok { + continue + } + if other == 1 { + messages = append(messages, fmt.Sprintf("And %d %s %s", other, kind, otherMessage)) + } + if other > 1 { + messages = append(messages, fmt.Sprintf("And %d %s %s", other, kindPlural, otherMessage)) + } + } + } + + return messages +} + +func sortMessage(i, j string, messageMustGoFirst map[string]bool, messagePriorityMap map[string]MergePriority, messageObjMapForKind map[string][]string) bool { + if messageMustGoFirst[i] && !messageMustGoFirst[j] { + return true + } + if !messageMustGoFirst[i] && messageMustGoFirst[j] { + return false + } + + if messagePriorityMap[i] < messagePriorityMap[j] { + return true + } + if messagePriorityMap[i] > messagePriorityMap[j] { + return false + } + + if len(messageObjMapForKind[i]) > len(messageObjMapForKind[j]) { + return true + } + if len(messageObjMapForKind[i]) < len(messageObjMapForKind[j]) { + return false + } + + return strings.Join(messageObjMapForKind[i], ",") < strings.Join(messageObjMapForKind[j], ",") +} + +func sortObj(i, j string, cpMachines sets.Set[string]) bool { + if cpMachines.Has(i) && !cpMachines.Has(j) { + return true + } + if !cpMachines.Has(i) && cpMachines.Has(j) { + return false + } + return i < j +} + +var re = regexp.MustCompile(`\s*\*\s+`) + +func indentIfMultiline(m string) string { + msg := "" + // If it is a multiline string or if it start with a bullet, indent the message. + if strings.Contains(m, "\n") || re.MatchString(m) { + msg += "\n" + + // Split the message in lines, and add a prefix; prefix can be + // " " when indenting a line starting in a bullet + // " * " when indenting a line starting without a bullet (indent + add a bullet) + // " " when indenting a line starting with a bullet, but other lines required adding a bullet + lines := strings.Split(m, "\n") + prefix := " " + hasLinesWithoutBullet := false + for i := range lines { + if !re.MatchString(lines[i]) { + hasLinesWithoutBullet = true + break + } + } + for i, l := range lines { + prefix := prefix + if hasLinesWithoutBullet { + if !re.MatchString(lines[i]) { + prefix += "* " + } else { + prefix += " " + } + } + lines[i] = prefix + l + } + msg += strings.Join(lines, "\n") + } else { + msg += " " + m + } + return msg +} + +// getConditionsWithOwnerInfo return all the conditions from an object each one with the corresponding ConditionOwnerInfo. +func getConditionsWithOwnerInfo(obj Getter) []ConditionWithOwnerInfo { + ret := make([]ConditionWithOwnerInfo, 0, 10) + conditions := obj.GetV1Beta2Conditions() + ownerInfo := getConditionOwnerInfo(obj) + for _, condition := range conditions { + ret = append(ret, ConditionWithOwnerInfo{ + OwnerResource: ownerInfo, + Condition: condition, + }) + } + return ret +} + +// getConditionOwnerInfo return the ConditionOwnerInfo for the given object. +// Note: Given that controller runtime often does not set typeMeta for objects, +// in case kind is missing we are falling back to the type name, which in most cases +// is the same as kind. +func getConditionOwnerInfo(obj any) ConditionOwnerInfo { + var kind, name string + var isControlPlaneMachine bool + if runtimeObject, ok := obj.(runtime.Object); ok { + kind = runtimeObject.GetObjectKind().GroupVersionKind().Kind + } + + if kind == "" { + t := reflect.TypeOf(obj) + if t.Kind() == reflect.Pointer { + kind = t.Elem().Name() + } else { + kind = t.Name() + } + } + + if objMeta, ok := obj.(objectWithNameAndLabels); ok { + name = objMeta.GetName() + if kind == "Machine" { + _, isControlPlaneMachine = objMeta.GetLabels()[clusterv1.MachineControlPlaneLabel] + } + } + + return ConditionOwnerInfo{ + Kind: kind, + Name: name, + IsControlPlaneMachine: isControlPlaneMachine, + } +} + +// objectWithNameAndLabels is a subset of metav1.Object. +type objectWithNameAndLabels interface { + GetName() string + GetLabels() map[string]string +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies_test.go b/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies_test.go new file mode 100644 index 000000000000..7b70270b6b2f --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/merge_strategies_test.go @@ -0,0 +1,408 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestSortMessages(t *testing.T) { + g := NewWithT(t) + messageMustGoFirst := map[string]bool{"m1": true} + messagePriorityMap := map[string]MergePriority{"m1": InfoMergePriority, "m2": IssueMergePriority, "m3": UnknownMergePriority, "m4": InfoMergePriority, "m5": UnknownMergePriority, "m6": UnknownMergePriority, "m7": UnknownMergePriority} + messageObjMapForKind := map[string][]string{"m1": {}, "m2": {"foo"}, "m3": {"foo", "bar"}, "m4": {"foo", "bar", "baz"}, "m5": {"a", "b"}, "m6": {"a"}, "m7": {"b"}} + + // Control plane goes before not control planes + got := sortMessage("m1", "m2", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + got = sortMessage("m1", "m5", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + // Not control plane goes after control planes + got = sortMessage("m2", "m1", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) + got = sortMessage("m5", "m1", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) + + // issues goes before unknown + got = sortMessage("m2", "m3", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + // unknown goes after issues + got = sortMessage("m3", "m2", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) + + // unknown goes before info + got = sortMessage("m3", "m4", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + // info goes after unknown + got = sortMessage("m4", "m3", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) + + // 2 objects goes before 1 object + got = sortMessage("m5", "m6", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + // 1 object goes after 2 objects + got = sortMessage("m6", "m5", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) + + // 1 object "a" goes before 1 object "b" + got = sortMessage("m6", "m7", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeTrue()) + // 1 object "b" goes after 1 object "a" + got = sortMessage("m7", "m6", messageMustGoFirst, messagePriorityMap, messageObjMapForKind) + g.Expect(got).To(BeFalse()) +} + +func TestSortObj(t *testing.T) { + g := NewWithT(t) + cpMachines := sets.Set[string]{} + cpMachines.Insert("m3") + + // Control plane goes before not control planes + got := sortObj("m3", "m1", cpMachines) + g.Expect(got).To(BeTrue()) + got = sortObj("m1", "m3", cpMachines) + g.Expect(got).To(BeFalse()) + + // machines must be sorted alphabetically + got = sortObj("m1", "m2", cpMachines) + g.Expect(got).To(BeTrue()) + got = sortObj("m2", "m1", cpMachines) + g.Expect(got).To(BeFalse()) +} + +func TestSummaryMessages(t *testing.T) { + d := &defaultMergeStrategy{ + getPriorityFunc: GetDefaultMergePriorityFunc(), + } + t.Run("When status is not true, drop info messages", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + // NOTE: objects are intentionally not in order so we can validate they are sorted by name + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Reason: "Reason-A", Message: "Message-A", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "B", Reason: "Reason-B", Message: "Message-B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "C", Reason: "Reason-C", Message: "Message-C", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "D", Reason: "Reason-D", Status: metav1.ConditionFalse}}, + } + + message := summaryMessage(conditions, d, metav1.ConditionFalse) + + g.Expect(message).To(Equal("* A: Message-A\n" + + "* B: Message-B\n" + + // Info message of true condition C was dropped + "* D: Reason-D")) // False conditions without messages must show the reason + }) + t.Run("When status is true, surface only not empty messages", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + // NOTE: objects are intentionally not in order so we can validate they are sorted by name + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Reason: "Reason-A", Message: "Message-A", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "B", Reason: "Reason-B", Message: "Message-B", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "C", Reason: "Reason-C", Status: metav1.ConditionTrue}}, + } + + message := summaryMessage(conditions, d, metav1.ConditionTrue) + + g.Expect(message).To(Equal("* A: Message-A\n" + + "* B: Message-B")) + }) + t.Run("Handles multiline messages", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Reason: "Reason-A", Message: "Message-A", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "B", Reason: "Reason-B", Message: "* Message-B", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "C", Reason: "Reason-C", Message: "* Message-C1\n* Message-C2", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "D", Reason: "Reason-D", Message: "Message-D\n* More Message-D", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "E", Reason: "Reason-E", Message: "* Message-E1\n * More Message-E1\n* Message-E2", Status: metav1.ConditionTrue}}, + } + + message := summaryMessage(conditions, d, metav1.ConditionTrue) + + expected := + // not multiline messages stay on a single line. + "* A: Message-A\n" + + // single line messages but starting with a bullet gets nested. + "* B:\n" + + " * Message-B\n" + + // multiline messages gets nested. + "* C:\n" + + " * Message-C1\n" + + " * Message-C2\n" + + // multiline messages with some lines without bullets gets a bulled and nested. + "* D:\n" + + " * Message-D\n" + + " * More Message-D\n" + + // nesting in multiline messages is preserved. + "* E:\n" + + " * Message-E1\n" + + " * More Message-E1\n" + + " * Message-E2" + g.Expect(message).To(Equal(expected)) + }) +} + +func TestAggregateMessages(t *testing.T) { + t.Run("Groups by kind, return max 3 messages, aggregate objects, count others", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + // NOTE: objects are intentionally not in order so we can validate they are sorted by name + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj02"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj04"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj03"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj06"}, Condition: metav1.Condition{Type: "A", Message: "* Message-3A\n* Message-3B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj05"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj08"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj07"}, Condition: metav1.Condition{Type: "A", Message: "Message-4", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj09"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj10"}, Condition: metav1.Condition{Type: "A", Message: "Message-5", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineSet", Name: "obj11"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineSet", Name: "obj12"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + } + + n := 3 + messages := aggregateMessages(conditions, &n, false, GetDefaultMergePriorityFunc(), map[MergePriority]string{IssueMergePriority: "with other issues"}) + + g.Expect(n).To(Equal(0)) + g.Expect(messages).To(Equal([]string{ + "* MachineDeployments obj01, obj02, obj05, ... (2 more): Message-1", // MachineDeployments obj08, obj09 + "* MachineDeployments obj03, obj04:\n" + + " * Message-2", + "* MachineDeployment obj06:\n" + + " * Message-3A\n" + + " * Message-3B", + "And 2 MachineDeployments with other issues", // MachineDeployments obj07 (Message-4), obj10 (Message-5) + "And 2 MachineSets with other issues", // MachineSet obj11, obj12 (Message-1) + })) + }) + t.Run("Issue messages goes before unknown", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + // NOTE: objects are intentionally not in order so we can validate they are sorted by name + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj02"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj04"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj03"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj06"}, Condition: metav1.Condition{Type: "A", Message: "* Message-3A\n* Message-3B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj05"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj08"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj07"}, Condition: metav1.Condition{Type: "A", Message: "Message-4\n* More Message-4", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj09"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj10"}, Condition: metav1.Condition{Type: "A", Message: "Message-5", Status: metav1.ConditionFalse}}, + } + + n := 3 + messages := aggregateMessages(conditions, &n, false, GetDefaultMergePriorityFunc(), map[MergePriority]string{IssueMergePriority: "with other issues", UnknownMergePriority: "with status unknown"}) + + g.Expect(n).To(Equal(0)) + g.Expect(messages).To(Equal([]string{ + "* MachineDeployments obj03, obj04:\n" + + " * Message-2", + "* MachineDeployment obj06:\n" + + " * Message-3A\n" + + " * Message-3B", + "* MachineDeployment obj07:\n" + + " * Message-4\n" + + " * More Message-4", + "And 1 MachineDeployment with other issues", // MachineDeployments obj10 (Message-5) + "And 5 MachineDeployments with status unknown", // MachineDeployments obj01, obj02, obj05, obj08, obj09 (Message 1) << This doesn't show up because even if it applies to 5 machines because it has merge priority unknown + })) + }) + t.Run("Control plane machines always goes before other machines", func(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + // NOTE: objects are intentionally not in order so we can validate they are sorted by name + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj02", IsControlPlaneMachine: true}, Condition: metav1.Condition{Type: "A", Message: "* Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Message: "* Message-1", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj04"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj03"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj06"}, Condition: metav1.Condition{Type: "A", Message: "* Message-3A\n* Message-3B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "Machine", Name: "obj05"}, Condition: metav1.Condition{Type: "A", Message: "* Message-2", Status: metav1.ConditionFalse}}, + } + + n := 3 + messages := aggregateMessages(conditions, &n, false, GetDefaultMergePriorityFunc(), map[MergePriority]string{IssueMergePriority: "with other issues", UnknownMergePriority: "with status unknown"}) + + g.Expect(n).To(Equal(0)) + g.Expect(messages).To(Equal([]string{ + "* Machines obj02, obj01:\n" + // control plane machines always go first, no matter if priority or number of objects (note, cp machine also go first in the machine list) + " * Message-1", + "* Machines obj03, obj04, obj05:\n" + + " * Message-2", + "* Machine obj06:\n" + + " * Message-3A\n" + + " * Message-3B", + })) + }) +} + +func TestSortConditions(t *testing.T) { + g := NewWithT(t) + + t0 := metav1.Now() + t1 := metav1.Time{Time: t0.Add(10 * time.Minute)} + t2 := metav1.Time{Time: t0.Add(20 * time.Minute)} + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + } + + orderedConditionTypes := []string{"A", "B", "!C"} + sortConditions(conditions, orderedConditionTypes) + + // Check conditions are sorted by orderedConditionTypes and by LastTransitionTime + + g.Expect(conditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + })) +} + +func TestSplitConditionsByPriority(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue}}, // info + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue}}, // info + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse}}, // info + } + + issueConditions, unknownConditions, infoConditions := splitConditionsByPriority(conditions, GetDefaultMergePriorityFunc("!C")) + + // Check condition are grouped as expected and order is preserved. + + g.Expect(issueConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue}}, + })) + + g.Expect(unknownConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown}}, + })) + + g.Expect(infoConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse}}, + })) +} + +func TestDefaultMergePriority(t *testing.T) { + tests := []struct { + name string + condition metav1.Condition + negativePolarity bool + wantPriority MergePriority + }{ + { + name: "Issue (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionFalse}, + negativePolarity: false, + wantPriority: IssueMergePriority, + }, + { + name: "Unknown (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionUnknown}, + negativePolarity: false, + wantPriority: UnknownMergePriority, + }, + { + name: "Info (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionTrue}, + negativePolarity: false, + wantPriority: InfoMergePriority, + }, + { + name: "NoStatus (PositivePolarity)", + condition: metav1.Condition{Type: "foo"}, + negativePolarity: false, + wantPriority: UnknownMergePriority, + }, + { + name: "Issue (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionTrue}, + negativePolarity: true, + wantPriority: IssueMergePriority, + }, + { + name: "Unknown (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionUnknown}, + negativePolarity: true, + wantPriority: UnknownMergePriority, + }, + { + name: "Info (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionFalse}, + negativePolarity: true, + wantPriority: InfoMergePriority, + }, + { + name: "NoStatus (NegativePolarity)", + condition: metav1.Condition{Type: "foo"}, + negativePolarity: true, + wantPriority: UnknownMergePriority, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + negativePolarityConditionTypes := []string{} + if tt.negativePolarity { + negativePolarityConditionTypes = append(negativePolarityConditionTypes, tt.condition.Type) + } + gotPriority := GetDefaultMergePriorityFunc(negativePolarityConditionTypes...)(tt.condition) + + g.Expect(gotPriority).To(Equal(tt.wantPriority)) + }) + } +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/mirror.go b/util/deprecated/v1beta1/conditions/v1beta2/mirror.go new file mode 100644 index 000000000000..bbf5986fe079 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/mirror.go @@ -0,0 +1,139 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// NotYetReportedReason is set on missing conditions generated during mirror, aggregate or summary operations. +// Missing conditions are generated during the above operations when an expected condition does not exist on a object. +// TODO: Move to the API package. +const NotYetReportedReason = "NotYetReported" + +// MirrorOption is some configuration that modifies options for a mirror call. +type MirrorOption interface { + // ApplyToMirror applies this configuration to the given mirror options. + ApplyToMirror(*MirrorOptions) +} + +// MirrorOptions allows to set options for the mirror operation. +type MirrorOptions struct { + targetConditionType string + fallbackStatus metav1.ConditionStatus + fallbackReason string + fallbackMessage string +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *MirrorOptions) ApplyOptions(opts []MirrorOption) *MirrorOptions { + for _, opt := range opts { + opt.ApplyToMirror(o) + } + return o +} + +// NewMirrorCondition create a mirror of the given condition from obj; if the given condition does not exist in the source obj, +// the condition specified in the FallbackCondition is used; if this option is not set, a new condition with status Unknown +// and reason NotYetReported is created. +// +// By default, the Mirror condition has the same type as the source condition, but this can be changed by using +// the TargetConditionType option. +func NewMirrorCondition(sourceObj Getter, sourceConditionType string, opts ...MirrorOption) *metav1.Condition { + condition := Get(sourceObj, sourceConditionType) + + return newMirrorCondition(condition, sourceConditionType, opts) +} + +func newMirrorCondition(sourceCondition *metav1.Condition, sourceConditionType string, opts []MirrorOption) *metav1.Condition { + mirrorOpt := &MirrorOptions{ + targetConditionType: sourceConditionType, + } + mirrorOpt.ApplyOptions(opts) + + if sourceCondition != nil { + return &metav1.Condition{ + Type: mirrorOpt.targetConditionType, + Status: sourceCondition.Status, + // NOTE: we are preserving the original transition time (when the underlying condition changed) + LastTransitionTime: sourceCondition.LastTransitionTime, + Reason: sourceCondition.Reason, + Message: sourceCondition.Message, + // NOTE: ObservedGeneration will be set when this condition is added to an object by calling Set + // (also preserving ObservedGeneration from the source object will be confusing when the mirror conditions shows up in the target object). + } + } + + if mirrorOpt.fallbackStatus != "" { + return &metav1.Condition{ + Type: mirrorOpt.targetConditionType, + Status: mirrorOpt.fallbackStatus, + Reason: mirrorOpt.fallbackReason, + Message: mirrorOpt.fallbackMessage, + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + } + } + + return &metav1.Condition{ + Type: mirrorOpt.targetConditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported", sourceConditionType), + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + } +} + +// SetMirrorCondition is a convenience method that calls NewMirrorCondition to create a mirror condition from the source object, +// and then calls Set to add the new condition to the target object. +func SetMirrorCondition(sourceObj Getter, targetObj Setter, sourceConditionType string, opts ...MirrorOption) { + mirrorCondition := NewMirrorCondition(sourceObj, sourceConditionType, opts...) + Set(targetObj, *mirrorCondition) +} + +// NewMirrorConditionFromUnstructured is a convenience method create a mirror of the given condition from the unstructured source obj. +// It combines, UnstructuredGet, NewMirrorCondition (most specifically it uses only the logic to +// create a mirror condition). +func NewMirrorConditionFromUnstructured(sourceObj runtime.Unstructured, sourceConditionType string, opts ...MirrorOption) (*metav1.Condition, error) { + condition, err := UnstructuredGet(sourceObj, sourceConditionType) + if err != nil { + return nil, err + } + return newMirrorCondition(condition, sourceConditionType, opts), nil +} + +// SetMirrorConditionFromUnstructured is a convenience method that mirror of the given condition from the unstructured source obj +// into the target object. It combines, NewMirrorConditionFromUnstructured, and Set. +func SetMirrorConditionFromUnstructured(sourceObj runtime.Unstructured, targetObj Setter, sourceConditionType string, opts ...MirrorOption) error { + condition, err := NewMirrorConditionFromUnstructured(sourceObj, sourceConditionType, opts...) + if err != nil { + return err + } + Set(targetObj, *condition) + return nil +} + +// BoolToStatus converts a bool to either metav1.ConditionTrue or metav1.ConditionFalse. +func BoolToStatus(status bool) metav1.ConditionStatus { + if status { + return metav1.ConditionTrue + } + return metav1.ConditionFalse +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/mirror_test.go b/util/deprecated/v1beta1/conditions/v1beta2/mirror_test.go new file mode 100644 index 000000000000..ba0d0006a6da --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/mirror_test.go @@ -0,0 +1,112 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestMirrorStatusCondition(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + tests := []struct { + name string + conditions []metav1.Condition + conditionType string + options []MirrorOption + want metav1.Condition + }{ + { + name: "Mirror a condition", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", LastTransitionTime: now}, + }, + { + name: "Mirror a condition with target type", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{TargetConditionType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", LastTransitionTime: now}, + }, + { + name: "Mirror a condition with empty message", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "", LastTransitionTime: now}, + }, + { + name: "Mirror a condition not yet reported", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionUnknown, Reason: NotYetReportedReason, Message: "Condition Ready not yet reported"}, + }, + { + name: "Mirror a condition not yet reported with target type", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{TargetConditionType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionUnknown, Reason: NotYetReportedReason, Message: "Condition Ready not yet reported"}, + }, + { + name: "Mirror a condition not yet reported with a fallback condition", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{ + FallbackCondition{ + Status: BoolToStatus(true), + Reason: "SomeReason", + Message: "Foo", + }, + }, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "SomeReason", Message: "Foo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions, + }, + } + + got := NewMirrorCondition(obj, tt.conditionType, tt.options...) + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(Equal(tt.want)) + }) + } +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/options.go b/util/deprecated/v1beta1/conditions/v1beta2/options.go new file mode 100644 index 000000000000..c212fb90089b --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/options.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// ConditionSortFunc defines the sort order when conditions are assigned to an object. +type ConditionSortFunc func(i, j metav1.Condition) bool + +// ApplyToSet applies this configuration to the given Set options. +func (f ConditionSortFunc) ApplyToSet(opts *SetOptions) { + opts.conditionSortFunc = f +} + +// ApplyToPatchApply applies this configuration to the given patch apply options. +func (f ConditionSortFunc) ApplyToPatchApply(opts *PatchApplyOptions) { + opts.conditionSortFunc = f +} + +// TargetConditionType allows to specify the type of new mirror or aggregate conditions. +type TargetConditionType string + +// ApplyToMirror applies this configuration to the given mirror options. +func (t TargetConditionType) ApplyToMirror(opts *MirrorOptions) { + opts.targetConditionType = string(t) +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t TargetConditionType) ApplyToAggregate(opts *AggregateOptions) { + opts.targetConditionType = string(t) +} + +// FallbackCondition defines the condition that should be returned by mirror if the source condition +// does not exist. +type FallbackCondition struct { + Status metav1.ConditionStatus + Reason string + Message string +} + +// ApplyToMirror applies this configuration to the given mirror options. +func (f FallbackCondition) ApplyToMirror(opts *MirrorOptions) { + opts.fallbackStatus = f.Status + opts.fallbackReason = f.Reason + opts.fallbackMessage = f.Message +} + +// ForConditionTypes allows to define the set of conditions in scope for a summary operation. +// Please note that condition types have an implicit order that can be used by the summary operation to determine relevance of the different conditions. +type ForConditionTypes []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t ForConditionTypes) ApplyToSummary(opts *SummaryOptions) { + opts.conditionTypes = t +} + +// NegativePolarityConditionTypes allows to define polarity for some of the conditions in scope for a summary or an aggregate operation. +type NegativePolarityConditionTypes []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t NegativePolarityConditionTypes) ApplyToSummary(opts *SummaryOptions) { + opts.negativePolarityConditionTypes = t +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t NegativePolarityConditionTypes) ApplyToAggregate(opts *AggregateOptions) { + opts.negativePolarityConditionTypes = t +} + +// IgnoreTypesIfMissing allows to define conditions types that should be ignored (not defaulted to unknown) when performing a summary operation. +type IgnoreTypesIfMissing []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t IgnoreTypesIfMissing) ApplyToSummary(opts *SummaryOptions) { + opts.ignoreTypesIfMissing = t +} + +// OverrideConditions allows to override conditions read from the source object only for the scope of a summary operation. +// The condition on the source object will preserve the original value. +type OverrideConditions []ConditionWithOwnerInfo + +// ApplyToSummary applies this configuration to the given summary options. +func (t OverrideConditions) ApplyToSummary(opts *SummaryOptions) { + opts.overrideConditions = t +} + +// CustomMergeStrategy allows to define a custom merge strategy when creating new summary or aggregate conditions. +type CustomMergeStrategy struct { + MergeStrategy +} + +// ApplyToSummary applies this configuration to the given summary options. +func (t CustomMergeStrategy) ApplyToSummary(opts *SummaryOptions) { + opts.mergeStrategy = t +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t CustomMergeStrategy) ApplyToAggregate(opts *AggregateOptions) { + opts.mergeStrategy = t +} + +// OwnedConditionTypes allows to define condition types owned by the controller when performing patch apply. +// In case of conflicts for the owned conditions, the patch helper will always use the value provided by the controller. +type OwnedConditionTypes []string + +// ApplyToPatchApply applies this configuration to the given patch apply options. +func (o OwnedConditionTypes) ApplyToPatchApply(opts *PatchApplyOptions) { + opts.ownedConditionTypes = o +} + +// ForceOverwrite instructs patch apply to always use the value provided by the controller (no matter of what value exists currently). +type ForceOverwrite bool + +// ApplyToPatchApply applies this configuration to the given patch apply options. +func (f ForceOverwrite) ApplyToPatchApply(opts *PatchApplyOptions) { + opts.forceOverwrite = bool(f) +} + +// GetPriorityFunc defines priority of a given condition when processed by the DefaultMergeStrategy. +// Note: The return value must be one of IssueMergePriority, UnknownMergePriority, InfoMergePriority. +type GetPriorityFunc func(condition metav1.Condition) MergePriority + +// ApplyToDefaultMergeStrategy applies this configuration to the given DefaultMergeStrategy options. +func (f GetPriorityFunc) ApplyToDefaultMergeStrategy(opts *DefaultMergeStrategyOptions) { + opts.getPriorityFunc = f +} + +// TargetConditionHasPositivePolarity defines the polarity of the condition returned by the DefaultMergeStrategy. +type TargetConditionHasPositivePolarity bool + +// ApplyToDefaultMergeStrategy applies this configuration to the given DefaultMergeStrategy options. +func (t TargetConditionHasPositivePolarity) ApplyToDefaultMergeStrategy(opts *DefaultMergeStrategyOptions) { + opts.targetConditionHasPositivePolarity = bool(t) +} + +// ComputeReasonFunc defines a function to be used when computing the reason of the condition returned by the DefaultMergeStrategy. +type ComputeReasonFunc func(issueConditions []ConditionWithOwnerInfo, unknownConditions []ConditionWithOwnerInfo, infoConditions []ConditionWithOwnerInfo) string + +// ApplyToDefaultMergeStrategy applies this configuration to the given DefaultMergeStrategy options. +func (f ComputeReasonFunc) ApplyToDefaultMergeStrategy(opts *DefaultMergeStrategyOptions) { + opts.computeReasonFunc = f +} + +// SummaryMessageTransformFunc defines a function to be used when computing the message for a summary condition returned by the DefaultMergeStrategy. +type SummaryMessageTransformFunc func([]string) []string + +// ApplyToDefaultMergeStrategy applies this configuration to the given DefaultMergeStrategy options. +func (f SummaryMessageTransformFunc) ApplyToDefaultMergeStrategy(opts *DefaultMergeStrategyOptions) { + opts.summaryMessageTransformFunc = f +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/patch.go b/util/deprecated/v1beta1/conditions/v1beta2/patch.go new file mode 100644 index 000000000000..939ff41ff01b --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/patch.go @@ -0,0 +1,254 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "reflect" + "sort" + + "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/util" +) + +// Patch defines a list of operations to change a list of conditions into another. +type Patch []PatchOperation + +// PatchOperation defines an operation that changes a single condition. +type PatchOperation struct { + Before *metav1.Condition + After *metav1.Condition + Op PatchOperationType +} + +// PatchOperationType defines a condition patch operation type. +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 Patch required to align source conditions to after conditions. +func NewPatch(before, after Getter) (Patch, error) { + var patch Patch + + if util.IsNil(before) { + return nil, errors.New("error creating patch: before object is nil") + } + beforeConditions := before.GetV1Beta2Conditions() + + if util.IsNil(after) { + return nil, errors.New("error creating patch: after object is nil") + } + afterConditions := after.GetV1Beta2Conditions() + + // Identify AddCondition and ModifyCondition changes. + for i := range afterConditions { + afterCondition := afterConditions[i] + beforeCondition := meta.FindStatusCondition(beforeConditions, afterCondition.Type) + if beforeCondition == nil { + patch = append(patch, PatchOperation{Op: AddConditionPatch, After: &afterCondition}) + continue + } + + if !reflect.DeepEqual(&afterCondition, beforeCondition) { + patch = append(patch, PatchOperation{Op: ChangeConditionPatch, After: &afterCondition, Before: beforeCondition}) + } + } + + // Identify RemoveCondition changes. + for i := range beforeConditions { + beforeCondition := beforeConditions[i] + afterCondition := meta.FindStatusCondition(afterConditions, beforeCondition.Type) + if afterCondition == nil { + patch = append(patch, PatchOperation{Op: RemoveConditionPatch, Before: &beforeCondition}) + } + } + return patch, nil +} + +// PatchApplyOption is some configuration that modifies options for a patch apply call. +type PatchApplyOption interface { + // ApplyToPatchApply applies this configuration to the given patch apply options. + ApplyToPatchApply(option *PatchApplyOptions) +} + +// PatchApplyOptions allows to set strategies for patch apply. +type PatchApplyOptions struct { + ownedConditionTypes []string + forceOverwrite bool + conditionSortFunc ConditionSortFunc +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *PatchApplyOptions) ApplyOptions(opts []PatchApplyOption) *PatchApplyOptions { + for _, opt := range opts { + if util.IsNil(opt) { + continue + } + opt.ApplyToPatchApply(o) + } + return o +} + +func (o *PatchApplyOptions) isOwnedConditionType(conditionType string) bool { + for _, i := range o.ownedConditionTypes { + if i == conditionType { + return true + } + } + return false +} + +// 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, opts ...PatchApplyOption) error { + if p.IsZero() { + return nil + } + + if util.IsNil(latest) { + return errors.New("error patching conditions: latest object is nil") + } + latestConditions := latest.GetV1Beta2Conditions() + + applyOpt := &PatchApplyOptions{ + // By default, sort conditions by the default condition order: available and ready always first, deleting and paused always last, all the other conditions in alphabetical order. + conditionSortFunc: defaultSortLessFunc, + } + applyOpt.ApplyOptions(opts) + + for _, conditionPatch := range p { + switch conditionPatch.Op { + case AddConditionPatch: + // If the condition is owned, always keep the after value. + if applyOpt.forceOverwrite || applyOpt.isOwnedConditionType(conditionPatch.After.Type) { + setStatusCondition(&latestConditions, *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 := meta.FindStatusCondition(latestConditions, conditionPatch.After.Type); latestCondition != nil { + // If latest and after disagree 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. + setStatusCondition(&latestConditions, *conditionPatch.After) + + case ChangeConditionPatch: + // If the conditions is owned, always keep the after value. + if applyOpt.forceOverwrite || applyOpt.isOwnedConditionType(conditionPatch.After.Type) { + setStatusCondition(&latestConditions, *conditionPatch.After) + continue + } + + latestCondition := meta.FindStatusCondition(latestConditions, 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. + setStatusCondition(&latestConditions, *conditionPatch.After) + + case RemoveConditionPatch: + // If latestConditions is nil or empty, nothing to remove. + if len(latestConditions) == 0 { + continue + } + + // If the conditions is owned, always keep the after value (condition should be deleted). + if applyOpt.forceOverwrite || applyOpt.isOwnedConditionType(conditionPatch.Before.Type) { + meta.RemoveStatusCondition(&latestConditions, 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 := meta.FindStatusCondition(latestConditions, 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. + meta.RemoveStatusCondition(&latestConditions, conditionPatch.Before.Type) + } + } + + if applyOpt.conditionSortFunc != nil { + sort.SliceStable(latestConditions, func(i, j int) bool { + return applyOpt.conditionSortFunc(latestConditions[i], latestConditions[j]) + }) + } + + latest.SetV1Beta2Conditions(latestConditions) + return nil +} + +// IsZero returns true if the patch is nil or has no changes. +func (p Patch) IsZero() bool { + if p == nil { + return true + } + return len(p) == 0 +} + +// HasSameState returns true if a condition has the same state of another; state is defined +// by the union of following fields: Type, Status, Reason, ObservedGeneration and Message (it excludes LastTransitionTime). +func HasSameState(i, j *metav1.Condition) bool { + return i.Type == j.Type && + i.Status == j.Status && + i.ObservedGeneration == j.ObservedGeneration && + i.Reason == j.Reason && + i.Message == j.Message +} + +// HasSameStateExceptObservedGeneration 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 ObservedGeneration and LastTransitionTime). +func HasSameStateExceptObservedGeneration(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/util/deprecated/v1beta1/conditions/v1beta2/patch_test.go b/util/deprecated/v1beta1/conditions/v1beta2/patch_test.go new file mode 100644 index 000000000000..b43c6ef6f9ac --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/patch_test.go @@ -0,0 +1,368 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestNewPatch(t *testing.T) { + now := metav1.Now() + fooTrue := metav1.Condition{Type: "foo", Status: metav1.ConditionTrue, LastTransitionTime: now} + fooFalse := metav1.Condition{Type: "foo", Status: metav1.ConditionFalse, LastTransitionTime: now} + + tests := []struct { + name string + before Setter + after Setter + want Patch + wantErr bool + }{ + { + name: "nil before return error", + before: nil, + after: objectWithConditions(), + wantErr: true, + }, + { + name: "nil after return error", + before: objectWithConditions(), + after: nil, + wantErr: true, + }, + { + name: "nil Interface before return error", + before: nilObject(), + after: objectWithConditions(), + wantErr: true, + }, + { + name: "nil Interface after return error", + before: objectWithConditions(), + after: nilObject(), + wantErr: true, + }, + { + name: "No changes return empty patch", + before: objectWithConditions(), + after: objectWithConditions(), + want: nil, + wantErr: false, + }, + + { + name: "No changes return empty patch", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooTrue), + want: nil, + }, + { + name: "Detects AddConditionPatch", + before: objectWithConditions(), + after: objectWithConditions(fooTrue), + want: Patch{ + { + Before: nil, + After: &fooTrue, + Op: AddConditionPatch, + }, + }, + }, + { + name: "Detects ChangeConditionPatch", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + want: Patch{ + { + Before: &fooTrue, + After: &fooFalse, + Op: ChangeConditionPatch, + }, + }, + }, + { + name: "Detects RemoveConditionPatch", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + want: Patch{ + { + Before: &fooTrue, + After: nil, + Op: RemoveConditionPatch, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := NewPatch(tt.before, tt.after) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(Not(HaveOccurred())) + g.Expect(got).To(BeComparableTo(tt.want)) + }) + } +} + +func TestApply(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + fooTrue := metav1.Condition{Type: "foo", Status: metav1.ConditionTrue, LastTransitionTime: now} + fooFalse := metav1.Condition{Type: "foo", Status: metav1.ConditionFalse, LastTransitionTime: now} + fooFalse2 := metav1.Condition{Type: "foo", Status: metav1.ConditionFalse, Reason: "Something else", LastTransitionTime: now} + + addMilliseconds := func(c metav1.Condition) metav1.Condition { + c1 := c.DeepCopy() + c1.LastTransitionTime.Time = c1.LastTransitionTime.Add(10 * time.Millisecond) + return *c1 + } + + tests := []struct { + name string + before Setter + after Setter + latest Setter + options []PatchApplyOption + want []metav1.Condition + wantErr bool + }{ + { + name: "error with nil interface object", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: nilObject(), + want: []metav1.Condition{fooTrue}, + wantErr: true, + }, + { + name: "error with nil latest object", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: nil, + want: []metav1.Condition{fooTrue}, + wantErr: true, + }, + { + name: "No patch return same list", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooTrue), + latest: objectWithConditions(fooTrue), + want: []metav1.Condition{fooTrue}, + wantErr: false, + }, + { + name: "Add: When a condition does not exists, it should add", + before: objectWithConditions(), + after: objectWithConditions(addMilliseconds(fooTrue)), // this will force the test to fail if an AddConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(), + want: []metav1.Condition{fooTrue}, + wantErr: false, + }, + { + name: "Add: When a condition already exists but without conflicts, it should add", + before: objectWithConditions(), + after: objectWithConditions(fooTrue), + latest: objectWithConditions(fooTrue), + want: []metav1.Condition{fooTrue}, + wantErr: false, + }, + { + name: "Add: When a condition already exists but with conflicts, it should error", + before: objectWithConditions(), + after: objectWithConditions(fooTrue), + latest: objectWithConditions(fooFalse), + want: nil, + wantErr: true, + }, + { + name: "Add: When a condition already exists but with conflicts, it should not error if force override is set", + before: objectWithConditions(), + after: objectWithConditions(addMilliseconds(fooTrue)), // this will force the test to fail if an AddConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(fooFalse), + options: []PatchApplyOption{ForceOverwrite(true)}, + want: []metav1.Condition{fooTrue}, // after condition should be kept in case of error + wantErr: false, + }, + { + name: "Add: When a condition already exists but with conflicts, it should not error if the condition is owned", + before: objectWithConditions(), + after: objectWithConditions(addMilliseconds(fooTrue)), // this will force the test to fail if an AddConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(fooFalse), + options: []PatchApplyOption{OwnedConditionTypes{"foo"}}, + want: []metav1.Condition{fooTrue}, // after condition should be kept in case of error + wantErr: false, + }, + { + name: "Remove: When a condition was already deleted, it should pass", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + latest: objectWithConditions(), + want: []metav1.Condition{}, + wantErr: false, + }, + { + name: "Remove: When a condition already exists but without conflicts, it should delete", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + latest: objectWithConditions(fooTrue), + want: []metav1.Condition{}, + wantErr: false, + }, + { + name: "Remove: When a condition already exists but with conflicts, it should error", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + latest: objectWithConditions(fooFalse), + want: nil, + wantErr: true, + }, + { + name: "Remove: When a condition already exists but with conflicts, it should not error if force override is set", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + latest: objectWithConditions(fooFalse), + options: []PatchApplyOption{ForceOverwrite(true)}, + want: []metav1.Condition{}, + wantErr: false, + }, + { + name: "Remove: When a condition already exists but with conflicts, it should not error if the condition is owned", + before: objectWithConditions(fooTrue), + after: objectWithConditions(), + latest: objectWithConditions(fooFalse), + options: []PatchApplyOption{OwnedConditionTypes{"foo"}}, + want: []metav1.Condition{}, + wantErr: false, + }, + { + name: "Change: When a condition exists without conflicts, it should change", + before: objectWithConditions(fooTrue), + after: objectWithConditions(addMilliseconds(fooFalse)), // this will force the test to fail if an ChangeConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(fooTrue), + want: []metav1.Condition{fooFalse}, + wantErr: false, + }, + { + name: "Change: When a condition exists with conflicts but there is agreement on the final state, it should change", + before: objectWithConditions(fooFalse), + after: objectWithConditions(fooTrue), + latest: objectWithConditions(fooTrue), + want: []metav1.Condition{fooTrue}, + wantErr: false, + }, + { + name: "Change: When a condition exists with conflicts but there is no agreement on the final state, it should error", + before: objectWithConditions(fooFalse), + after: objectWithConditions(fooFalse2), + latest: objectWithConditions(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 force override is set", + before: objectWithConditions(fooFalse), + after: objectWithConditions(addMilliseconds(fooFalse2)), // this will force the test to fail if an ChangeConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(fooTrue), + options: []PatchApplyOption{ForceOverwrite(true)}, + want: []metav1.Condition{fooFalse2}, + wantErr: false, + }, + { + 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: objectWithConditions(fooFalse), + after: objectWithConditions(addMilliseconds(fooFalse2)), // this will force the test to fail if an ChangeConditionPatch operation doesn't drop milliseconds + latest: objectWithConditions(fooTrue), + options: []PatchApplyOption{OwnedConditionTypes{"foo"}}, + want: []metav1.Condition{fooFalse2}, + wantErr: false, + }, + { + name: "Change: When a condition was deleted, it should error", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: objectWithConditions(), + want: nil, + wantErr: true, + }, + { + name: "Change: When a condition was deleted, it should not error if force override is set", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: objectWithConditions(), + options: []PatchApplyOption{ForceOverwrite(true)}, + want: []metav1.Condition{fooFalse}, + wantErr: false, + }, + { + name: "Change: When a condition was deleted, it should not error if the condition is owned", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: objectWithConditions(), + options: []PatchApplyOption{OwnedConditionTypes{"foo"}}, + want: []metav1.Condition{fooFalse}, + wantErr: false, + }, + { + name: "Error when nil passed as an ApplyOption", + before: objectWithConditions(fooTrue), + after: objectWithConditions(fooFalse), + latest: objectWithConditions(), + options: []PatchApplyOption{nil}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Ignore the error here to allow testing of patch.Apply with a nil patch + 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()) + + gotConditions := tt.latest.GetV1Beta2Conditions() + g.Expect(gotConditions).To(MatchConditions(tt.want)) + }) + } +} + +func objectWithConditions(conditions ...metav1.Condition) Setter { + obj := &builder.Phase3Obj{} + obj.Status.Conditions = conditions + return obj +} + +func nilObject() Setter { + var obj *builder.Phase3Obj + return obj +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/setter.go b/util/deprecated/v1beta1/conditions/v1beta2/setter.go new file mode 100644 index 000000000000..75b1042b94e7 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/setter.go @@ -0,0 +1,127 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "sort" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/util" +) + +// Setter interface defines methods that a Cluster API object should implement in order to +// use the conditions package for setting conditions. +type Setter interface { + Getter + + // SetV1Beta2Conditions sets conditions for an API object. + // Note: SetV1Beta2Conditions will be renamed to SetConditions in a later stage of the transition to V1Beta2. + SetV1Beta2Conditions([]metav1.Condition) +} + +// SetOption is some configuration that modifies options for a Set request. +type SetOption interface { + // ApplyToSet applies this configuration to the given Set options. + ApplyToSet(option *SetOptions) +} + +// SetOptions allows to define options for the set operation. +type SetOptions struct { + conditionSortFunc ConditionSortFunc +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *SetOptions) ApplyOptions(opts []SetOption) *SetOptions { + for _, opt := range opts { + opt.ApplyToSet(o) + } + return o +} + +// Set a condition on the given object implementing the Setter interface; if the object is nil, the operation is a no-op. +// +// When setting a condition: +// - condition.ObservedGeneration will be set to object.Metadata.Generation if targetObj is a metav1.Object. +// - If the condition does not exist and condition.LastTransitionTime is not set, time.Now is used. +// - If the condition already exists, condition.Status is changing and condition.LastTransitionTime is not set, time.Now is used. +// - If the condition already exists, condition.Status is NOT changing, all the fields can be changed except for condition.LastTransitionTime. +// +// Additionally, Set enforces a default condition order (Available and Ready fist, everything else in alphabetical order), +// but this can be changed by using the ConditionSortFunc option. +// +// Please note that Set does not support setting conditions to an unstructured object nor to API types not implementing +// the Setter interface. Eventually, users can implement wrappers on those types implementing this interface and +// taking care of aligning the condition format if necessary. +func Set(targetObj Setter, condition metav1.Condition, opts ...SetOption) { + if util.IsNil(targetObj) { + return + } + + setOpt := &SetOptions{ + // By default, sort conditions by the default condition order: available and ready always first, deleting and paused always last, all the other conditions in alphabetical order. + conditionSortFunc: defaultSortLessFunc, + } + setOpt.ApplyOptions(opts) + + if objMeta, ok := targetObj.(metav1.Object); ok { + condition.ObservedGeneration = objMeta.GetGeneration() + } + + conditions := targetObj.GetV1Beta2Conditions() + if changed := setStatusCondition(&conditions, condition); !changed { + return + } + + if setOpt.conditionSortFunc != nil { + sort.SliceStable(conditions, func(i, j int) bool { + return setOpt.conditionSortFunc(conditions[i], conditions[j]) + }) + } + + targetObj.SetV1Beta2Conditions(conditions) +} + +func setStatusCondition(conditions *[]metav1.Condition, condition metav1.Condition) bool { + // Truncate last transition time to seconds. + // This prevents inconsistencies from what we have in objects in memory and what Marshal/Unmarshal + // will do while the data is sent to/read from the API server. + if condition.LastTransitionTime.IsZero() { + condition.LastTransitionTime = metav1.Now() + } + condition.LastTransitionTime.Time = condition.LastTransitionTime.Truncate(1 * time.Second) + return meta.SetStatusCondition(conditions, condition) +} + +// Delete deletes the condition with the given type. +func Delete(to Setter, conditionType string) { + if to == nil { + return + } + + conditions := to.GetV1Beta2Conditions() + newConditions := make([]metav1.Condition, 0, len(conditions)-1) + for _, condition := range conditions { + if condition.Type != conditionType { + newConditions = append(newConditions, condition) + } + } + to.SetV1Beta2Conditions(newConditions) +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/setter_test.go b/util/deprecated/v1beta1/conditions/v1beta2/setter_test.go new file mode 100644 index 000000000000..21c2fea303e6 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/setter_test.go @@ -0,0 +1,235 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestSet(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + condition := metav1.Condition{ + Type: "fooCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 0, // NOTE: this is a dedicated tests about inferring ObservedGeneration. + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + } + + cloneCondition := func() metav1.Condition { + return *condition.DeepCopy() + } + + t.Run("no-op with nil", func(_ *testing.T) { + condition := cloneCondition() + Set(nil, condition) + }) + + t.Run("handles pointer to nil object", func(_ *testing.T) { + var foo *builder.Phase1Obj + condition := cloneCondition() + Set(foo, condition) + }) + + t.Run("Phase1Obj object with both legacy and v1beta2 conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "bazCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: &builder.Phase1ObjV1Beta2Status{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + }, + }, + }, + } + + condition := cloneCondition() + expected := []metav1.Condition{ + foo.Status.V1Beta2.Conditions[0], + condition, + } + + Set(foo, condition) + g.Expect(foo.Status.V1Beta2.Conditions).To(Equal(expected), cmp.Diff(foo.Status.V1Beta2.Conditions, expected)) + }) + + t.Run("Phase2Obj object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + }, + Deprecated: &builder.Phase2ObjDeprecatedStatus{ + V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + }, + } + + condition := cloneCondition() + expected := []metav1.Condition{ + foo.Status.Conditions[0], + condition, + } + + Set(foo, condition) + g.Expect(foo.Status.Conditions).To(Equal(expected), cmp.Diff(foo.Status.Conditions, expected)) + }) + + t.Run("Phase3Obj object with conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "barCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + { + Type: "zzzCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + }, + }, + } + + condition := cloneCondition() + expected := []metav1.Condition{ + foo.Status.Conditions[0], + condition, + foo.Status.Conditions[1], + } + + Set(foo, condition) + g.Expect(foo.Status.Conditions).To(Equal(expected), cmp.Diff(foo.Status.Conditions, expected)) + }) + + t.Run("Set infers ObservedGeneration", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{Generation: 123}, + Status: builder.Phase3ObjStatus{ + Conditions: nil, + }, + } + + condition := metav1.Condition{ + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + } + + Set(foo, condition) + + condition.ObservedGeneration = foo.Generation + expected := []metav1.Condition{condition} + g.Expect(foo.Status.Conditions).To(Equal(expected), cmp.Diff(foo.Status.Conditions, expected)) + }) + + t.Run("Set drops milliseconds", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{Generation: 123}, + Status: builder.Phase3ObjStatus{ + Conditions: nil, + }, + } + + condition := metav1.Condition{ + Type: "fooCondition", + Status: metav1.ConditionTrue, + Reason: "FooReason", + Message: "FooMessage", + } + + // Check LastTransitionTime after setting a condition for the first time + Set(foo, condition) + ltt1 := foo.Status.Conditions[0].LastTransitionTime.Time + g.Expect(ltt1).To(Equal(ltt1.Truncate(1*time.Second)), cmp.Diff(ltt1, ltt1.Truncate(1*time.Second))) + + // Check LastTransitionTime after changing an existing condition + condition.Status = metav1.ConditionFalse // this will force set to change the LastTransitionTime + condition.LastTransitionTime = metav1.Time{} // this will force set to compute a new LastTransitionTime + Set(foo, condition) + ltt2 := foo.Status.Conditions[0].LastTransitionTime.Time + g.Expect(ltt2).To(Equal(ltt2.Truncate(1*time.Second)), cmp.Diff(ltt2, ltt2.Truncate(1*time.Second))) + + // Check LastTransitionTime after setting a Time with milliseconds + condition.Status = metav1.ConditionTrue // this will force set to change the LastTransitionTime + condition.LastTransitionTime = metav1.Now() // this will force set to not default LastTransitionTime + Set(foo, condition) + ltt3 := foo.Status.Conditions[0].LastTransitionTime.Time + g.Expect(ltt3).To(Equal(ltt3.Truncate(1*time.Second)), cmp.Diff(ltt3, ltt3.Truncate(1*time.Second))) + }) +} + +func TestDelete(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{ + {Type: "trueCondition", Status: metav1.ConditionTrue}, + {Type: "falseCondition", Status: metav1.ConditionFalse}, + }, + }, + } + + Delete(nil, "foo") // no-op + Delete(obj, "trueCondition") + Delete(obj, "trueCondition") // no-op + + g.Expect(obj.GetV1Beta2Conditions()).To(MatchConditions([]metav1.Condition{{Type: "falseCondition", Status: metav1.ConditionFalse}}, IgnoreLastTransitionTime(true))) +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/sort.go b/util/deprecated/v1beta1/conditions/v1beta2/sort.go new file mode 100644 index 000000000000..f48a03b8983d --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/sort.go @@ -0,0 +1,151 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +var orderMap = map[string]int{} + +func init() { + for i, c := range order { + orderMap[c] = i + } +} + +// defaultSortLessFunc returns true if a condition is less than another with regards to the +// order of conditions designed for convenience of the consumer, i.e. kubectl get. +// According to this order the Available and the Ready condition always goes first, Deleting and Paused always goes last, +// and all the other conditions are sorted by Type. +func defaultSortLessFunc(i, j metav1.Condition) bool { + fi, oki := orderMap[i.Type] + if !oki { + fi = orderMap[readinessAndAvailabilityGates] + } + fj, okj := orderMap[j.Type] + if !okj { + fj = orderMap[readinessAndAvailabilityGates] + } + return fi < fj || + (fi == fj && i.Type < j.Type) +} + +// The order array below leads to the following condition ordering: +// +// | Condition | Cluster | KCP | MD | MS | MP | Machine | +// |--------------------------------|---------|-----|:---|----|----|---------| +// | -- Availability conditions -- | | | | | | | +// | Available | x | x | x | | x | x | +// | Ready | | | | | | x | +// | UpToDate | | | | | | x | +// | RemoteConnectionProbe | x | | | | | | +// | BootstrapConfigReady | | | | | x | x | +// | InfrastructureReady | x | | | | x | x | +// | ControlPlaneInitialized | x | | | | | | +// | ControlPlaneAvailable | x | | | | | | +// | WorkersAvailable | x | | | | | | +// | CertificatesAvailable | | x | | | | | +// | Initialized | | x | | | | | +// | EtcdClusterHealthy | | x | | | | | +// | ControlPlaneComponentsHealthy | | x | | | | | +// | NodeHealthy | | | | | | x | +// | NodeReady | | | | | | x | +// | EtcdPodHealthy | | | | | | x | +// | EtcdMemberHealthy | | | | | | x | +// | APIServerPodHealthy | | | | | | x | +// | ControllerManagerPodHealthy | | | | | | x | +// | SchedulerPodHealthy | | | | | | x | +// | HealthCheckSucceeded | | | | | | x | +// | OwnerRemediated | | | | | | x | +// | -- Operations -- | | | | | | | +// | TopologyReconciled | x | | | | | | +// | RollingOut | x | x | x | | x | | +// | Remediating | x | x | x | x | x | | +// | ScalingDown | x | x | x | x | x | | +// | ScalingUp | x | x | x | x | x | | +// | -- Aggregated from Machines -- | | | | | | | +// | MachinesReady | | x | x | x | x | | +// | ControlPlaneMachinesReady | x | | | | | | +// | WorkerMachinesReady | x | | | | | | +// | MachinesUpToDate | | x | x | x | x | | +// | ControlPlaneMachinesUpToDate | x | | | | | | +// | WorkerMachinesUpToDate | x | | | | | | +// | -- From other controllers -- | | | | | | | +// | Readiness/Availability gates | x | | | | | x | +// | -- Misc -- | | | | | | | +// | Paused | x | x | x | x | x | x | +// | Deleting | x | x | x | x | x | x | +// . +var order = []string{ + clusterv1.AvailableV1Beta2Condition, + clusterv1.ReadyV1Beta2Condition, + clusterv1.MachineUpToDateV1Beta2Condition, + clusterv1.ClusterRemoteConnectionProbeV1Beta2Condition, + clusterv1.BootstrapConfigReadyV1Beta2Condition, + clusterv1.InfrastructureReadyV1Beta2Condition, + clusterv1.ClusterControlPlaneInitializedV1Beta2Condition, + clusterv1.ClusterControlPlaneAvailableV1Beta2Condition, + clusterv1.ClusterWorkersAvailableV1Beta2Condition, + kubeadmControlPlaneCertificatesAvailableV1Beta2Condition, + kubeadmControlPlaneInitializedV1Beta2Condition, + kubeadmControlPlaneEtcdClusterHealthyV1Beta2Condition, + kubeadmControlPlaneControlPlaneComponentsHealthyV1Beta2Condition, + clusterv1.MachineNodeHealthyV1Beta2Condition, + clusterv1.MachineNodeReadyV1Beta2Condition, + kubeadmControlPlaneMachineEtcdPodHealthyV1Beta2Condition, + kubeadmControlPlaneMachineEtcdMemberHealthyV1Beta2Condition, + kubeadmControlPlaneMachineAPIServerPodHealthyV1Beta2Condition, + kubeadmControlPlaneMachineControllerManagerPodHealthyV1Beta2Condition, + kubeadmControlPlaneMachineSchedulerPodHealthyV1Beta2Condition, + clusterv1.MachineHealthCheckSucceededV1Beta2Condition, + clusterv1.MachineOwnerRemediatedV1Beta2Condition, + clusterv1.ClusterTopologyReconciledV1Beta2Condition, + clusterv1.RollingOutV1Beta2Condition, + clusterv1.RemediatingV1Beta2Condition, + clusterv1.ScalingDownV1Beta2Condition, + clusterv1.ScalingUpV1Beta2Condition, + clusterv1.MachinesReadyV1Beta2Condition, + clusterv1.ClusterControlPlaneMachinesReadyV1Beta2Condition, + clusterv1.ClusterWorkerMachinesReadyV1Beta2Condition, + clusterv1.MachinesUpToDateV1Beta2Condition, + clusterv1.ClusterControlPlaneMachinesUpToDateV1Beta2Condition, + clusterv1.ClusterWorkerMachinesUpToDateV1Beta2Condition, + readinessAndAvailabilityGates, + clusterv1.PausedV1Beta2Condition, + clusterv1.DeletingV1Beta2Condition, +} + +// Constants defining a placeholder for readiness and availability gates. +const ( + readinessAndAvailabilityGates = "" +) + +// Constants inlined for ordering (we want to avoid importing the KCP API package). +const ( + kubeadmControlPlaneCertificatesAvailableV1Beta2Condition = "CertificatesAvailable" + kubeadmControlPlaneInitializedV1Beta2Condition = "Initialized" + kubeadmControlPlaneEtcdClusterHealthyV1Beta2Condition = "EtcdClusterHealthy" + kubeadmControlPlaneControlPlaneComponentsHealthyV1Beta2Condition = "ControlPlaneComponentsHealthy" + kubeadmControlPlaneMachineAPIServerPodHealthyV1Beta2Condition = "APIServerPodHealthy" + kubeadmControlPlaneMachineControllerManagerPodHealthyV1Beta2Condition = "ControllerManagerPodHealthy" + kubeadmControlPlaneMachineSchedulerPodHealthyV1Beta2Condition = "SchedulerPodHealthy" + kubeadmControlPlaneMachineEtcdPodHealthyV1Beta2Condition = "EtcdPodHealthy" + kubeadmControlPlaneMachineEtcdMemberHealthyV1Beta2Condition = "EtcdMemberHealthy" +) diff --git a/util/deprecated/v1beta1/conditions/v1beta2/sort_test.go b/util/deprecated/v1beta1/conditions/v1beta2/sort_test.go new file mode 100644 index 000000000000..2badfcd5313e --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/sort_test.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "sort" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestDefaultSortLessFunc(t *testing.T) { + g := NewWithT(t) + + conditions := []metav1.Condition{ + {Type: clusterv1.DeletingV1Beta2Condition}, + {Type: "B"}, + {Type: clusterv1.AvailableV1Beta2Condition}, + {Type: "A"}, + {Type: clusterv1.PausedV1Beta2Condition}, + {Type: clusterv1.ReadyV1Beta2Condition}, + {Type: "C!"}, + } + + sort.Slice(conditions, func(i, j int) bool { + return defaultSortLessFunc(conditions[i], conditions[j]) + }) + + g.Expect(conditions).To(Equal([]metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition}, + {Type: clusterv1.ReadyV1Beta2Condition}, + {Type: "A"}, + {Type: "B"}, + {Type: "C!"}, + {Type: clusterv1.PausedV1Beta2Condition}, + {Type: clusterv1.DeletingV1Beta2Condition}, + })) +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/summary.go b/util/deprecated/v1beta1/conditions/v1beta2/summary.go new file mode 100644 index 000000000000..beb5fd461a71 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/summary.go @@ -0,0 +1,163 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +// SummaryOption is some configuration that modifies options for a summary call. +type SummaryOption interface { + // ApplyToSummary applies this configuration to the given summary options. + ApplyToSummary(*SummaryOptions) +} + +// SummaryOptions allows to set options for the summary operation. +type SummaryOptions struct { + mergeStrategy MergeStrategy + conditionTypes []string + negativePolarityConditionTypes []string + ignoreTypesIfMissing []string + overrideConditions []ConditionWithOwnerInfo +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *SummaryOptions) ApplyOptions(opts []SummaryOption) *SummaryOptions { + for _, opt := range opts { + opt.ApplyToSummary(o) + } + return o +} + +// NewSummaryCondition creates a new condition by summarizing a set of conditions from an object; the list of +// types of the conditions to be summarized must be provided with the ForConditionTypes option; +// conditions types with negative polarity, should be indicated with the NegativePolarityConditionTypes option. +// +// If any of the condition in scope does not exist in the source object, missing conditions are considered Unknown, reason NotYetReported. +// Use the IgnoreTypesIfMissing to exclude types from this option. +// +// Additionally, it is possible to inject custom merge strategies using the CustomMergeStrategy option or +// to add a step counter to the generated message by using the StepCounter option. +func NewSummaryCondition(sourceObj Getter, targetConditionType string, opts ...SummaryOption) (*metav1.Condition, error) { + summarizeOpt := &SummaryOptions{} + summarizeOpt.ApplyOptions(opts) + if summarizeOpt.mergeStrategy == nil { + // Note. Summary always assume the target condition type has positive polarity. + summarizeOpt.mergeStrategy = DefaultMergeStrategy(GetPriorityFunc(GetDefaultMergePriorityFunc(summarizeOpt.negativePolarityConditionTypes...))) + } + + if len(summarizeOpt.conditionTypes) == 0 { + return nil, errors.New("option ForConditionTypes not provided or empty") + } + + for _, conditionType := range summarizeOpt.conditionTypes { + if conditionType == targetConditionType { + return nil, errors.Errorf("option ForConditionTypes cannot include %s (target condition type)", targetConditionType) + } + } + + expectedConditionTypes := sets.New[string](summarizeOpt.conditionTypes...) + ignoreTypesIfMissing := sets.New[string](summarizeOpt.ignoreTypesIfMissing...) + existingConditionTypes := sets.New[string]() + + conditions := getConditionsWithOwnerInfo(sourceObj) + + conditionsByType := map[string]ConditionWithOwnerInfo{} + for _, c := range conditions { + conditionsByType[c.Type] = c + } + overrideConditionsByType := map[string]ConditionWithOwnerInfo{} + for _, c := range summarizeOpt.overrideConditions { + if _, ok := overrideConditionsByType[c.Type]; ok { + return nil, errors.Errorf("override condition %s specified multiple times", c.Type) + } + + overrideConditionsByType[c.Type] = c + + if _, ok := conditionsByType[c.Type]; !ok { + return nil, errors.Errorf("override condition %s must exist in source object", c.Type) + } + } + + conditionsInScope := make([]ConditionWithOwnerInfo, 0, len(expectedConditionTypes)) + for _, condition := range conditions { + // Drops all the conditions not in scope for the merge operation + if !expectedConditionTypes.Has(condition.Type) { + continue + } + + if overrideCondition, ok := overrideConditionsByType[condition.Type]; ok { + conditionsInScope = append(conditionsInScope, overrideCondition) + } else { + conditionsInScope = append(conditionsInScope, condition) + } + + existingConditionTypes.Insert(condition.Type) + } + + // Add the expected conditions which do not exist, so we are compliant with K8s guidelines + // (all missing conditions should be considered unknown). + + diff := expectedConditionTypes.Difference(existingConditionTypes).Difference(ignoreTypesIfMissing).UnsortedList() + if len(diff) > 0 { + conditionOwner := getConditionOwnerInfo(sourceObj) + + for _, c := range diff { + conditionsInScope = append(conditionsInScope, ConditionWithOwnerInfo{ + OwnerResource: conditionOwner, + Condition: metav1.Condition{ + Type: c, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: "Condition not yet reported", + // NOTE: LastTransitionTime and ObservedGeneration are not relevant for merge. + }, + }) + } + } + + if len(conditionsInScope) == 0 { + return nil, errors.New("summary can't be performed when the list of conditions to be summarized is empty") + } + + status, reason, message, err := summarizeOpt.mergeStrategy.Merge(SummaryMergeOperation, conditionsInScope, summarizeOpt.conditionTypes) + if err != nil { + return nil, err + } + + return &metav1.Condition{ + Type: targetConditionType, + Status: status, + Reason: reason, + Message: message, + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + }, nil +} + +// SetSummaryCondition is a convenience method that calls NewSummaryCondition to create a summary condition from the source object, +// and then calls Set to add the new condition to the target object. +func SetSummaryCondition(sourceObj Getter, targetObj Setter, targetConditionType string, opts ...SummaryOption) error { + summaryCondition, err := NewSummaryCondition(sourceObj, targetConditionType, opts...) + if err != nil { + return err + } + Set(targetObj, *summaryCondition) + return nil +} diff --git a/util/deprecated/v1beta1/conditions/v1beta2/summary_test.go b/util/deprecated/v1beta1/conditions/v1beta2/summary_test.go new file mode 100644 index 000000000000..1d9bd0486dd4 --- /dev/null +++ b/util/deprecated/v1beta1/conditions/v1beta2/summary_test.go @@ -0,0 +1,400 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestSummary(t *testing.T) { + toLowerMsg := func(in []string) (out []string) { + out = make([]string, len(in)) + for i := range in { + out[i] = strings.ToLower(in[i]) + } + return + } + tests := []struct { + name string + conditions []metav1.Condition + conditionType string + options []SummaryOption + want *metav1.Condition + wantErr bool + }{ + { + name: "One issue", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* !C: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "One issue without message", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* !C: Reason-!C", // messages from all the issues & unknown conditions (info dropped); since message is empty, a default one is added + }, + }, + { + name: "More than one issue", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-B"}, // issue + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: issuesReportedReason, // Using a generic reason + Message: "* B: Message-B\n" + + "* !C: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one issue, some with multiline messages", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-B"}, // issue + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "* Message-!C1\n* Message-!C2"}, // issue + {Type: "D", Status: metav1.ConditionFalse, Reason: "Reason-D", Message: "Message-D\n* More message-D"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C", "D"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: issuesReportedReason, // Using a generic reason + Message: "* B: Message-B\n" + + "* !C:\n" + + " * Message-!C1\n" + + " * Message-!C2\n" + + "* D:\n" + + " * Message-D\n" + + " * More message-D", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one issue and one unknown condition", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-B"}, // issue + {Type: "A", Status: metav1.ConditionUnknown, Reason: "Reason-A", Message: "Message-A"}, // unknown + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: issuesReportedReason, // Using a generic reason + Message: "* A: Message-A\n" + + "* B: Message-B\n" + + "* !C: Message-!C", // messages from all the issues & unknown conditions (info dropped); also, the order defined in ForConditionTypes must be preserved. + }, + }, + { + name: "One unknown (no issues)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionUnknown, // Unknown because there is one unknown + Reason: unknownReportedReason, // Using a generic reason + Message: "* !C: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one unknown (no issues)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionUnknown, Reason: "Reason-B", Message: "Message-B"}, // unknown + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionUnknown, // Unknown because there are many unknown + Reason: unknownReportedReason, // Using a generic reason + Message: "* B: Message-B\n" + + "* !C: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + + { + name: "More than one info (no issues, no unknown)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: ""}, // info + {Type: "!C", Status: metav1.ConditionFalse, Reason: "Reason-!C", Message: "Message-!C"}, // info + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ + ForConditionTypes{"A", "B", "!C"}, + NegativePolarityConditionTypes{"!C"}, + CustomMergeStrategy{ + DefaultMergeStrategy( + TargetConditionHasPositivePolarity(true), + GetPriorityFunc(GetDefaultMergePriorityFunc("!C")), + SummaryMessageTransformFunc(toLowerMsg), + ), + }, + }, + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionTrue, // True because there are many info + Reason: infoReportedReason, // Using a generic reason + Message: "* b: message-b\n" + + "* !c: message-!c", // messages from all the info conditions (empty messages are dropped), all lower case due to the summary message transform fun + }, + }, + { + name: "Default missing conditions to unknown", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + // B and !C missing + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}}, // B and !C are required! + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionUnknown, // Unknown because there more than one unknown + Reason: unknownReportedReason, // Using a generic reason + Message: "* B: Condition not yet reported\n" + + "* !C: Condition not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Default missing conditions to unknown consider IgnoreTypesIfMissing", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + // B and !C missing + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}, IgnoreTypesIfMissing{"B"}}, // B and !C are required! + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionUnknown, // Unknown because there more than one unknown + Reason: unknownReportedReason, // Using a generic reason + Message: "* !C: Condition not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "No issue considering IgnoreTypesIfMissing", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + // B and !C missing + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!C"}, IgnoreTypesIfMissing{"B", "!C"}}, // A is required! + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionTrue, // True because B and !C are ignored + Reason: infoReportedReason, // Using a generic reason + Message: "* A: Message-A", // messages from A, the only existing info + }, + }, + { + name: "Ignore conditions not in scope", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: ""}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ForConditionTypes{"A", "B"}}, // C not in scope + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionTrue, // True because there are many info + Reason: infoReportedReason, // Using a generic reason + Message: "* B: Message-B", // messages from all the info conditions (empty messages are dropped) + }, + }, + { + name: "Override condition", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C", Message: "Message-C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ + ForConditionTypes{"A", "!C"}, + NegativePolarityConditionTypes{"!C"}, + IgnoreTypesIfMissing{"!C"}, + OverrideConditions{ + { + OwnerResource: ConditionOwnerInfo{ + Kind: "Phase3Obj", + Name: "SourceObject", + }, + Condition: metav1.Condition{ + Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C-additional", Message: "Message-C-additional", // issue + }, + }, + }, + }, // OverrideCondition replaces the same condition from the SourceObject + want: &metav1.Condition{ + Type: clusterv1.AvailableV1Beta2Condition, + Status: metav1.ConditionFalse, // False because !C is an issue + Reason: issuesReportedReason, // Using a generic reason + Message: "* !C: Message-C-additional", // Picking the message from the additional condition (info dropped) + }, + }, + { + name: "Error if ForConditionTypes is not set", + conditions: []metav1.Condition{}, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{}, + wantErr: true, + }, + { + name: "Error if ForConditionTypes includes target condition", + conditions: []metav1.Condition{}, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ + ForConditionTypes{clusterv1.AvailableV1Beta2Condition}, + }, + wantErr: true, + }, + { + name: "Error if the same override condition is specified multiple times", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C", Message: "Message-C"}, // issue + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ + ForConditionTypes{"A", "!C"}, + NegativePolarityConditionTypes{"!C"}, + IgnoreTypesIfMissing{"!C"}, + OverrideConditions{ + { + OwnerResource: ConditionOwnerInfo{ + Kind: "Phase3Obj", + Name: "SourceObject", + }, + Condition: metav1.Condition{ + Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C-additional", Message: "Message-C-additional", // issue + }, + }, + { + OwnerResource: ConditionOwnerInfo{ + Kind: "Phase3Obj", + Name: "SourceObject", + }, + Condition: metav1.Condition{ + Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C-additional", Message: "Message-C-additional", // issue + }, + }, + }, + }, // OverrideCondition is specified multiple times + wantErr: true, + }, + { + name: "Error if override condition does not exist in source object", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + // !C is missing in source object + }, + conditionType: clusterv1.AvailableV1Beta2Condition, + options: []SummaryOption{ + ForConditionTypes{"A", "!C"}, + NegativePolarityConditionTypes{"!C"}, + IgnoreTypesIfMissing{"!C"}, + OverrideConditions{ + { + OwnerResource: ConditionOwnerInfo{ + Kind: "Phase3Obj", + Name: "SourceObject", + }, + Condition: metav1.Condition{ + Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-C-additional", Message: "Message-C-additional", // issue + }, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions, + }, + } + + got, err := NewSummaryCondition(obj, tt.conditionType, tt.options...) + g.Expect(err != nil).To(Equal(tt.wantErr)) + + g.Expect(got).To(Equal(tt.want)) + }) + } + + t.Run("Fails if conditions type is not provided", func(t *testing.T) { + g := NewWithT(t) + obj := &builder.Phase3Obj{} + _, err := NewSummaryCondition(obj, clusterv1.AvailableV1Beta2Condition) // no ForConditionTypes --> Condition in scope will be empty + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("Fails if conditions in scope are empty", func(t *testing.T) { + g := NewWithT(t) + obj := &builder.Phase3Obj{} + _, err := NewSummaryCondition(obj, clusterv1.AvailableV1Beta2Condition, ForConditionTypes{"A"}, IgnoreTypesIfMissing{"A"}) // no condition for the object, missing condition ignored --> Condition in scope will be empty + g.Expect(err).To(HaveOccurred()) + }) +} diff --git a/util/deprecated/v1beta1/patch/doc.go b/util/deprecated/v1beta1/patch/doc.go new file mode 100644 index 000000000000..19a8c501443f --- /dev/null +++ b/util/deprecated/v1beta1/patch/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package patch implements patch utilities. +// +// Deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details. +package patch diff --git a/util/deprecated/v1beta1/patch/options.go b/util/deprecated/v1beta1/patch/options.go new file mode 100644 index 000000000000..ba185c6dfc94 --- /dev/null +++ b/util/deprecated/v1beta1/patch/options.go @@ -0,0 +1,118 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + +// 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 []clusterv1.ConditionType + + // OwnedV1Beta2Conditions 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. + OwnedV1Beta2Conditions []string + + // Metav1ConditionsFields allows to override the path for the field hosting []metav1.Condition. + // Please note that the default value for this option is inferred from the object struct. + // This means, that if the correct path cannot be detected, this option has to be specified. One example + // is if you pass a wrapper to unstructured. + // The override for this option is considered only if the object implements the v1beta2conditions.Setter interface. + Metav1ConditionsFieldPath []string + + // Clusterv1ConditionsFieldPath allows to override the path for the field hosting clusterv1.Conditions. + // Please note that the default value for this option is inferred from the object struct. + // This means, that if the correct path cannot be detected, this option has to be specified. One example + // is if you pass a wrapper to unstructured. + // The override for this option is considered only if the object implements the conditions.Setter interface. + Clusterv1ConditionsFieldPath []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 []clusterv1.ConditionType +} + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w WithOwnedConditions) ApplyToHelper(in *HelperOptions) { + in.OwnedConditions = w.Conditions +} + +// WithOwnedV1Beta2Conditions 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 WithOwnedV1Beta2Conditions struct { + Conditions []string +} + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w WithOwnedV1Beta2Conditions) ApplyToHelper(in *HelperOptions) { + in.OwnedV1Beta2Conditions = w.Conditions +} + +// Metav1ConditionsFieldPath allows to override the path for the field hosting []metav1.Condition. +// Please note that the default value for this option is inferred from the object struct. +// The override for this option is considered only if the object implements the v1beta2conditions.Setter interface. +type Metav1ConditionsFieldPath []string + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w Metav1ConditionsFieldPath) ApplyToHelper(in *HelperOptions) { + in.Metav1ConditionsFieldPath = w +} + +// Clusterv1ConditionsFieldPath allows to override the path for the field hosting clusterv1.Conditions. +// Please note that the default value for this option is inferred from the object struct. +// The override for this option is considered only if the object implements the conditions.Setter interface. +type Clusterv1ConditionsFieldPath []string + +// ApplyToHelper applies this configuration to the given HelperOptions. +func (w Clusterv1ConditionsFieldPath) ApplyToHelper(in *HelperOptions) { + in.Clusterv1ConditionsFieldPath = w +} diff --git a/util/deprecated/v1beta1/patch/patch.go b/util/deprecated/v1beta1/patch/patch.go new file mode 100644 index 000000000000..968c104c2a32 --- /dev/null +++ b/util/deprecated/v1beta1/patch/patch.go @@ -0,0 +1,413 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" + v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2" +) + +// Helper is a utility for ensuring the proper patching of objects. +type Helper struct { + client client.Client + gvk schema.GroupVersionKind + beforeObject client.Object + before *unstructured.Unstructured + after *unstructured.Unstructured + changes sets.Set[string] + + metav1ConditionsFieldPath []string + clusterv1ConditionsFieldPath []string +} + +// NewHelper returns an initialized Helper. Use NewHelper before changing +// obj. After changing obj use Helper.Patch to persist your changes. +// +// Please note that patch helper implements a custom handling for objects implementing +// the condition.Setter interface or the v1beta2conditions.Setter interface. +// +// It is also possible to implement wrappers for object not implementing those interfaces; +// in case those objects have custom conditions types the wrapper should take care of conversions. +// Additionally, if the conditions are not in the canonical place defined by the proposal for +// improving status in Cluster API conditions, locations of the condition field must be +// provided explicitly by using Metav1ConditionsFieldPath and Clusterv1ConditionsFieldPath options +// during the Patch call. +func NewHelper(obj client.Object, crClient client.Client) (*Helper, error) { + // Return early if the object is nil. + if util.IsNil(obj) { + return nil, errors.New("failed to create patch helper: object is nil") + } + + // Get the GroupVersionKind of the object, + // used to validate against later on. + gvk, err := apiutil.GVKForObject(obj, crClient.Scheme()) + if err != nil { + return nil, errors.Wrapf(err, "failed to create patch helper for object %s", klog.KObj(obj)) + } + + // Identify location of the condition fields according to the canonical place defined by the proposal for + // improving status in Cluster API conditions. + metav1ConditionsFieldPath, clusterv1ConditionsFieldPath, err := identifyConditionsFieldsPath(obj) + if err != nil { + return nil, errors.Wrapf(err, "failed to identify condition fields for object %s", klog.KObj(obj)) + } + + return &Helper{ + client: crClient, + gvk: gvk, + beforeObject: obj.DeepCopyObject().(client.Object), + metav1ConditionsFieldPath: metav1ConditionsFieldPath, + clusterv1ConditionsFieldPath: clusterv1ConditionsFieldPath, + }, 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 { + // Return early if the object is nil. + if util.IsNil(obj) { + return errors.Errorf("failed to patch %s %s: modified object is nil", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // Get the GroupVersionKind of the object that we want to patch. + gvk, err := apiutil.GVKForObject(obj, h.client.Scheme()) + if err != nil { + return errors.Wrapf(err, "failed to patch %s %s", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + if gvk != h.gvk { + return errors.Errorf("failed to patch %s %s: unmatched GroupVersionKind, expected %q got %q", h.gvk.Kind, klog.KObj(h.beforeObject), h.gvk, gvk) + } + + // Calculate the options. + options := &HelperOptions{} + for _, opt := range opts { + opt.ApplyToHelper(options) + } + + // If condition field path override have been provided, propagate them to the helper for usage in various places of this func. + if len(options.Clusterv1ConditionsFieldPath) > 0 { + h.clusterv1ConditionsFieldPath = options.Clusterv1ConditionsFieldPath + } + if len(options.Metav1ConditionsFieldPath) > 0 { + h.metav1ConditionsFieldPath = options.Metav1ConditionsFieldPath + } + + // Check if the object satisfies the Cluster API contract setter interfaces; if not, ignore condition field path entirely. + if _, canInterfaceConditions := obj.(conditions.Setter); !canInterfaceConditions { + h.clusterv1ConditionsFieldPath = nil + } + if _, canInterfaceV1Beta2Conditions := obj.(v1beta2conditions.Setter); !canInterfaceV1Beta2Conditions { + h.metav1ConditionsFieldPath = nil + } + + // Convert the before object to unstructured. + h.before, err = toUnstructured(h.beforeObject, gvk) + if err != nil { + return errors.Wrapf(err, "failed to patch %s %s: failed to convert before object to Unstructured", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // Convert the after object to unstructured. + h.after, err = toUnstructured(obj, gvk) + if err != nil { + return errors.Wrapf(err, "failed to patch %s %s: failed to convert after object to Unstructured", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // 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 errors.Wrapf(err, "failed to patch %s %s: failed to set .status.observedGeneration", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // Restore the changes back to the original object. + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(h.after.Object, obj); err != nil { + return errors.Wrapf(err, "failed to patch %s %s: failed to converted object from Unstructured", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + } + } + + // 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 errors.Wrapf(err, "failed to patch %s %s", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // Issue patches and return errors in an aggregate. + var errs []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. + if err := h.patchStatusConditions(ctx, obj, options.ForceOverwriteConditions, options.OwnedConditions, options.OwnedV1Beta2Conditions); err != nil { + errs = append(errs, err) + } + // Then proceed to patch the rest of the object. + if err := h.patch(ctx, obj); err != nil { + errs = append(errs, err) + } + + if err := h.patchStatus(ctx, obj); err != nil { + if !(apierrors.IsNotFound(err) && !obj.GetDeletionTimestamp().IsZero() && len(obj.GetFinalizers()) == 0) { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Wrapf(kerrors.NewAggregate(errs), "failed to patch %s %s", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + return nil +} + +// patch issues a patch for metadata and spec. +func (h *Helper) patch(ctx context.Context, obj client.Object) error { + if !h.shouldPatch(specPatch) { + 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(statusPatch) { + 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 []clusterv1.ConditionType, ownedV1beta2Conditions []string) error { + // Nothing to do if the object doesn't have conditions (doesn't have conditions identified as needing a special treatment). + if len(h.clusterv1ConditionsFieldPath) == 0 && len(h.metav1ConditionsFieldPath) == 0 { + return nil + } + + // If the object has clusterv1 conditions, create a function applying corresponding changes if any. + var clusterv1ApplyPatch func(client.Object) error + if len(h.clusterv1ConditionsFieldPath) > 0 { + // 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("%s %s doesn't satisfy conditions.Getter, cannot patch", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + after, ok := obj.(conditions.Getter) + if !ok { + return errors.Errorf("%s %s doesn't satisfy conditions.Getter, cannot compute patch", h.gvk.Kind, klog.KObj(obj)) + } + + diff, err := conditions.NewPatch( + before, + after, + ) + if err != nil { + return errors.Wrapf(err, "%s %s can not be patched", h.gvk.Kind, klog.KObj(before)) + } + if !diff.IsZero() { + clusterv1ApplyPatch = func(latest client.Object) error { + latestSetter, ok := latest.(conditions.Setter) + if !ok { + return errors.Errorf("%s %s doesn't satisfy conditions.Setter, cannot apply patch", h.gvk.Kind, klog.KObj(latest)) + } + + return diff.Apply(latestSetter, conditions.WithForceOverwrite(forceOverwrite), conditions.WithOwnedConditions(ownedConditions...)) + } + } + } + + // If the object has metav1 conditions, create a function applying corresponding changes if any. + var metav1ApplyPatch func(client.Object) error + if len(h.metav1ConditionsFieldPath) > 0 { + // 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.(v1beta2conditions.Getter) + if !ok { + return errors.Errorf("%s %s doesn't satisfy v1beta2conditions.Getter, cannot patch", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + after, ok := obj.(v1beta2conditions.Getter) + if !ok { + return errors.Errorf("%s %s doesn't satisfy v1beta2conditions.Getter, cannot compute patch", h.gvk.Kind, klog.KObj(obj)) + } + + diff, err := v1beta2conditions.NewPatch( + before, + after, + ) + if err != nil { + return errors.Wrapf(err, "%s %s can not be patched", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + if !diff.IsZero() { + metav1ApplyPatch = func(latest client.Object) error { + latestSetter, ok := latest.(v1beta2conditions.Setter) + if !ok { + return errors.Errorf("%s %s doesn't satisfy conditions.Setter, cannot apply patch", h.gvk.Kind, klog.KObj(latest)) + } + + return diff.Apply(latestSetter, v1beta2conditions.ForceOverwrite(forceOverwrite), v1beta2conditions.OwnedConditionTypes(ownedV1beta2Conditions)) + } + } + } + + // No changes to apply, return early. + if clusterv1ApplyPatch == nil && metav1ApplyPatch == nil { + return nil + } + + // Make a copy of the object and store the key used if we have conflicts. + key := client.ObjectKeyFromObject(obj) + + // 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 := h.beforeObject.DeepCopyObject().(client.Object) + if !ok { + return false, errors.Errorf("%s %s doesn't satisfy client.Object, cannot patch", h.gvk.Kind, klog.KObj(h.beforeObject)) + } + + // 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().(client.Object), client.MergeFromWithOptimisticLock{}) + + // Set the condition patch previously created on the new object. + if clusterv1ApplyPatch != nil { + if err := clusterv1ApplyPatch(latest); err != nil { + return false, err + } + } + if metav1ApplyPatch != nil { + if err := metav1ApplyPatch(latest); 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.clusterv1ConditionsFieldPath, h.metav1ConditionsFieldPath) + after := unsafeUnstructuredCopy(h.after, focus, h.clusterv1ConditionsFieldPath, h.metav1ConditionsFieldPath) + + // 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(focus patchType) bool { + if focus == specPatch { + // If we're looking to patch anything other than status, + // return true if the changes map has any fields after removing `status`. + return h.changes.Clone().Delete("status").Len() > 0 + } + return h.changes.Has(string(focus)) +} + +// 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) (sets.Set[string], 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 := sets.New[string]() + for key := range patchDiff { + res.Insert(key) + } + return res, nil +} diff --git a/util/deprecated/v1beta1/patch/patch_test.go b/util/deprecated/v1beta1/patch/patch_test.go new file mode 100644 index 000000000000..7def0c395b70 --- /dev/null +++ b/util/deprecated/v1beta1/patch/patch_test.go @@ -0,0 +1,2853 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + 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/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/external" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" + v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestPatchHelper(t *testing.T) { + ns, err := env.CreateNamespace(ctx, "test-patch-helper") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := env.Delete(ctx, ns); err != nil { + t.Fatal(err) + } + }() + + t.Run("should patch an unstructured object", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "GenericBootstrapConfig", + "apiVersion": "bootstrap.cluster.x-k8s.io/v1beta2", + "metadata": map[string]interface{}{ + "generateName": "test-bootstrap-", + "namespace": ns.Name, + }, + }, + } + + t.Run("adding an owner reference, preserving its status", func(t *testing.T) { + g := NewWithT(t) + + t.Log("Creating the unstructured object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.GetName(), Namespace: obj.GetNamespace()} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + obj.Object["status"] = map[string]interface{}{ + "ready": true, + } + g.Expect(env.Status().Update(ctx, obj)).To(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Modifying the OwnerReferences") + refs := []metav1.OwnerReference{ + { + APIVersion: "cluster.x-k8s.io/v1beta1", + Kind: "Cluster", + Name: "test", + UID: types.UID("fake-uid"), + }, + } + obj.SetOwnerReferences(refs) + + t.Log("Patching the unstructured object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating that the status has been preserved") + ready, err := external.IsReady(obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ready).To(BeTrue()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + return cmp.Equal(obj.GetOwnerReferences(), objAfter.GetOwnerReferences()) + }, timeout).Should(BeTrue()) + }) + }) + + t.Run("Should patch conditions", func(t *testing.T) { + t.Run("on a corev1.Node object", func(t *testing.T) { + g := NewWithT(t) + + conditionTime := metav1.Date(2015, 1, 1, 12, 0, 0, 0, metav1.Now().Location()) + obj := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "node-patch-test-", + Namespace: ns.Name, + Annotations: map[string]string{ + "test": "1", + }, + }, + } + + t.Log("Creating a Node object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.GetName()} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Appending a new condition") + condition := corev1.NodeCondition{ + Type: "CustomCondition", + Status: corev1.ConditionTrue, + LastHeartbeatTime: conditionTime, + LastTransitionTime: conditionTime, + Reason: "reason", + Message: "message", + } + obj.Status.Conditions = append(obj.Status.Conditions, condition) + + t.Log("Patching the Node") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + g.Expect(env.Get(ctx, key, objAfter)).To(Succeed()) + + ok, _ := ContainElement(condition).Match(objAfter.Status.Conditions) + return ok + }, timeout).Should(BeTrue()) + }) + + t.Run("on a Phase1 object", func(t *testing.T) { + obj := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: ns.Name, + }, + } + + t.Run("should mark it ready", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should recover if there is a resolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking TestCondition=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("TestCondition"), "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "TestCondition") + testConditionAfter := conditions.Get(objAfter, "TestCondition") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should recover if there is a resolvable conflict, incl. patch spec and status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking TestCondition=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("TestCondition"), "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Changing the object spec, status, and adding Ready=True condition") + obj.Spec.Foo = "Foo" + obj.Status.Bar = "Bar" + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := obj.DeepCopy() + g.Eventually(func() bool { + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "TestCondition") + testConditionAfter := conditions.Get(objAfter, "TestCondition") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo && + obj.Status.Bar == objAfter.Status.Bar + }, timeout).Should(BeTrue(), cmp.Diff(obj, objAfter)) + }) + + t.Run("should return an error if there is an unresolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + for _, afterCondition := range objAfter.Status.Conditions { + ok, err := conditions.MatchCondition(objCopy.Status.Conditions[0]).Match(afterCondition) + if err == nil && ok { + return true + } + } + + return false + }, timeout).Should(BeTrue()) + }) + + t.Run("should not return an error if there is an unresolvable conflict but the conditions is owned by the controller", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithOwnedConditions{Conditions: []clusterv1.ConditionType{clusterv1.ReadyCondition}})).To(Succeed()) + + t.Log("Validating the object has been updated") + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + g.Eventually(func() clusterv1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Condition{} + } + + return *conditions.Get(objAfter, clusterv1.ReadyCondition) + }, timeout).Should(conditions.MatchCondition(*readyBefore)) + }) + + t.Run("should not return an error if there is an unresolvable conflict when force overwrite is enabled", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithForceOverwriteConditions{})).To(Succeed()) + + t.Log("Validating the object has been updated") + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + g.Eventually(func() clusterv1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Condition{} + } + + return *conditions.Get(objAfter, clusterv1.ReadyCondition) + }, timeout).Should(conditions.MatchCondition(*readyBefore)) + }) + }) + }) + + t.Run("Should patch a Phase1Obj", func(t *testing.T) { + obj := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: ns.Name, + }, + } + + t.Run("add a finalizer", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Adding a finalizer") + obj.Finalizers = append(obj.Finalizers, clusterv1.ClusterFinalizer) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return cmp.Equal(obj.Finalizers, objAfter.Finalizers) + }, timeout).Should(BeTrue()) + }) + + t.Run("removing finalizers", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + obj.Finalizers = append(obj.Finalizers, clusterv1.ClusterFinalizer) + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Removing the finalizers") + obj.SetFinalizers(nil) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return len(objAfter.Finalizers) == 0 + }, timeout).Should(BeTrue()) + }) + + t.Run("updating spec", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Updating the object spec") + obj.Spec.Foo = "Foo" + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo + }, timeout).Should(BeTrue()) + }) + + t.Run("updating status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Updating the object status") + obj.Status.Bar = "Bar" + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + return cmp.Equal(objAfter.Status, obj.Status) + }, timeout).Should(BeTrue()) + }) + + t.Run("updating both spec, status, and adding a condition", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Updating the object spec") + obj.Spec.Foo = "Foo" + + t.Log("Updating the object status") + obj.Status.Bar = "Bar" + + t.Log("Setting Ready condition") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return cmp.Equal(obj.Status.Bar, objAfter.Status.Bar) && + conditions.IsTrue(objAfter, clusterv1.ReadyCondition) && + cmp.Equal(obj.Spec, objAfter.Spec) + }, timeout).Should(BeTrue()) + }) + }) + + t.Run("should patch a corev1.ConfigMap object", func(t *testing.T) { + g := NewWithT(t) + + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "node-patch-test-", + Namespace: ns.Name, + Annotations: map[string]string{ + "test": "1", + }, + }, + Data: map[string]string{ + "1": "value", + }, + } + + t.Log("Creating a ConfigMap object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := util.ObjectKey(obj) + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Adding a new Data value") + obj.Data["1"] = "value1" + obj.Data["2"] = "value2" + + t.Log("Patching the ConfigMap") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := &corev1.ConfigMap{} + g.Eventually(func() bool { + g.Expect(env.Get(ctx, key, objAfter)).To(Succeed()) + return len(objAfter.Data) == 2 + }, timeout).Should(BeTrue()) + g.Expect(objAfter.Data["1"]).To(Equal("value1")) + g.Expect(objAfter.Data["2"]).To(Equal("value2")) + }) + + t.Run("Should update Status.ObservedGeneration when using WithStatusObservedGeneration option", func(t *testing.T) { + obj := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-ms", + Namespace: ns.Name, + }, + } + + t.Run("when updating spec", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the MachineSet object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Updating the object spec") + obj.Spec.Foo = "Foo" + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithStatusObservedGeneration{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return cmp.Equal(obj.Spec, objAfter.Spec) && + obj.GetGeneration() == objAfter.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + }) + + t.Run("when updating spec, status, and metadata", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the MachineSet object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Updating the object spec") + obj.Spec.Foo = "Foo" + + t.Log("Updating the object status") + obj.Status.Bar = "Bar" + + t.Log("Updating the object metadata") + obj.ObjectMeta.Annotations = map[string]string{ + "test1": "annotation", + } + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithStatusObservedGeneration{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + return cmp.Equal(obj.Spec, objAfter.Spec) && + cmp.Equal(obj.Status, objAfter.Status) && + obj.GetGeneration() == objAfter.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + }) + + t.Run("without any changes", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the MachineSet object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + obj.Status.ObservedGeneration = obj.GetGeneration() + lastGeneration := obj.GetGeneration() + g.Expect(env.Status().Update(ctx, obj)).To(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithStatusObservedGeneration{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + return lastGeneration == objAfter.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + }) + }) + + t.Run("Should error if the object isn't the same", func(t *testing.T) { + g := NewWithT(t) + + cluster := &builder.Phase0Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: ns.Name, + }, + } + + machineSet := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-2", + Namespace: ns.Name, + }, + } + + g.Expect(env.Create(ctx, cluster)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, cluster)).To(Succeed()) + }() + g.Expect(env.Create(ctx, machineSet)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, machineSet)).To(Succeed()) + }() + + patcher, err := NewHelper(cluster, env) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(patcher.Patch(ctx, machineSet)).NotTo(Succeed()) + }) + + t.Run("Should not error if there are no finalizers and deletion timestamp is not nil", func(t *testing.T) { + g := NewWithT(t) + cluster := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: ns.Name, + Finalizers: []string{"block-deletion"}, + }, + Status: builder.Phase1ObjStatus{}, + } + key := client.ObjectKey{Name: cluster.GetName(), Namespace: cluster.GetNamespace()} + g.Expect(env.Create(ctx, cluster)).To(Succeed()) + g.Expect(env.Delete(ctx, cluster)).To(Succeed()) + + // Ensure cluster still exists & get Cluster with deletionTimestamp set + // Note: Using the APIReader to ensure we get the cluster with deletionTimestamp is set. + // This is realistic because finalizers are removed in reconcileDelete code and that is only + // run if the deletionTimestamp is set. + g.Expect(env.GetAPIReader().Get(ctx, key, cluster)).To(Succeed()) + + // Patch helper will first remove the finalizer and then it will get a not found error when + // trying to patch status. This test validates that the not found error is ignored. + patcher, err := NewHelper(cluster, env) + g.Expect(err).ToNot(HaveOccurred()) + cluster.Finalizers = []string{} + cluster.Status.Bar = "Bar" + g.Expect(patcher.Patch(ctx, cluster)).To(Succeed()) + }) +} + +func TestNewHelperNil(t *testing.T) { + var x *appsv1.Deployment + g := NewWithT(t) + _, err := NewHelper(x, nil) + g.Expect(err).To(HaveOccurred()) + _, err = NewHelper(nil, nil) + g.Expect(err).To(HaveOccurred()) +} + +func TestPatchHelperForV1beta2Transition(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + t.Run("Should patch conditions on a v1beta1 object with conditions (phase 0)", func(t *testing.T) { + obj := &builder.Phase0Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: metav1.NamespaceDefault, + }, + } + + t.Run("should mark it ready", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should mark it ready when passing Clusterv1ConditionsFieldPath", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, Clusterv1ConditionsFieldPath{"status", "conditions"})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should recover if there is a resolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a custom condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("TestCondition"), "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "TestCondition") + testConditionAfter := conditions.Get(objAfter, "TestCondition") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should recover if there is a resolvable conflict, incl. patch spec and status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a custom condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("TestCondition"), "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Changing the object spec, status, and adding Ready=True condition") + obj.Spec.Foo = "foo" + obj.Status.Bar = "bat" + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := obj.DeepCopy() + g.Eventually(func() bool { + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "TestCondition") + testConditionAfter := conditions.Get(objAfter, "TestCondition") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo && + obj.Status.Bar == objAfter.Status.Bar + }, timeout).Should(BeTrue(), cmp.Diff(obj, objAfter)) + }) + + t.Run("should return an error if there is an unresolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + for _, afterCondition := range objAfter.Status.Conditions { + ok, err := conditions.MatchCondition(objCopy.Status.Conditions[0]).Match(afterCondition) + if err == nil && ok { + return true + } + } + + return false + }, timeout).Should(BeTrue()) + }) + + t.Run("should not return an error if there is an unresolvable conflict but the condition is owned by the controller", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithOwnedConditions{Conditions: []clusterv1.ConditionType{clusterv1.ReadyCondition}})).To(Succeed()) + + t.Log("Validating the object has been updated") + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + g.Eventually(func() clusterv1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Condition{} + } + + return *conditions.Get(objAfter, clusterv1.ReadyCondition) + }, timeout).Should(conditions.MatchCondition(*readyBefore)) + }) + + t.Run("should not return an error if there is an unresolvable conflict when force overwrite is enabled", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking Ready=False") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithForceOverwriteConditions{})).To(Succeed()) + + t.Log("Validating the object has been updated") + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + g.Eventually(func() clusterv1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Condition{} + } + + return *conditions.Get(objAfter, clusterv1.ReadyCondition) + }, timeout).Should(conditions.MatchCondition(*readyBefore)) + }) + }) + + t.Run("Should patch conditions on a v1beta1 object with both clusterv1.conditions and metav1.conditions (phase 1)", func(t *testing.T) { + obj := &builder.Phase1Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: metav1.NamespaceDefault, + }, + } + + t.Run("should mark it ready and sort conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + // Adding Ready first + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking clusterv1.conditions and metav1.conditions Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + // Adding Available as a second condition, but it should be sorted as first + t.Log("Creating a new patch helper") + patcher, err = NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking metav1.conditions Available=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Available", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Conditions)) + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + if len(objAfter.Status.V1Beta2.Conditions) != 2 { + return nil + } + if objAfter.Status.V1Beta2.Conditions[0].Type != "Available" || objAfter.Status.V1Beta2.Conditions[1].Type != "Ready" { + return nil + } + + return objAfter.Status.V1Beta2.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.V1Beta2.Conditions)) + }) + + t.Run("should mark it ready when passing Clusterv1ConditionsFieldPath and Metav1ConditionsFieldPath", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking clusterv1.conditions and metav1.conditions Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, Clusterv1ConditionsFieldPath{"status", "conditions"}, Metav1ConditionsFieldPath{"status", "v1beta2", "conditions"})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Conditions)) + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.V1Beta2.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.V1Beta2.Conditions)) + }) + + t.Run("should recover if there is a resolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking clusterv1.conditions and metav1.conditions Test=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("Test"), "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking clusterv1.conditions and metav1.conditions Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "Test") + testConditionAfter := conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + testV1Beta2ConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testV1Beta2ConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testV1Beta2ConditionCopy == nil || testV1Beta2ConditionAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*testV1Beta2ConditionCopy).Match(*testV1Beta2ConditionAfter) + if err != nil || !ok { + return false + } + + readyV1Beta2Before := v1beta2conditions.Get(obj, "Ready") + readyV1Beta2After := v1beta2conditions.Get(objAfter, "Ready") + if readyV1Beta2Before == nil || readyV1Beta2After == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyV1Beta2Before).Match(*readyV1Beta2After) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should recover if there is a resolvable conflict, incl. patch spec and status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking clusterv1.conditions and metav1.conditions Test=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("Test"), "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Changing the object spec, status, and marking clusterv1.condition and metav1.conditions Ready=True") + obj.Spec.Foo = "foo" + obj.Status.Bar = "bat" + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := obj.DeepCopy() + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := conditions.Get(objCopy, "Test") + testConditionAfter := conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + testV1Beta2ConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testV1Beta2ConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testV1Beta2ConditionCopy == nil || testV1Beta2ConditionAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*testV1Beta2ConditionCopy).Match(*testV1Beta2ConditionAfter) + if err != nil || !ok { + return false + } + + readyV1Beta2Before := v1beta2conditions.Get(obj, "Ready") + readyV1Beta2After := v1beta2conditions.Get(objAfter, "Ready") + if readyV1Beta2Before == nil || readyV1Beta2After == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyV1Beta2Before).Match(*readyV1Beta2After) + if err != nil || !ok { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo && + obj.Status.Bar == objAfter.Status.Bar + }, timeout).Should(BeTrue(), cmp.Diff(obj, objAfter)) + }) + + t.Run("should return an error if there is an unresolvable conflict on conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Conditions + }, timeout).Should(conditions.MatchConditions(objCopy.Status.Conditions)) + }) + + t.Run("should return an error if there is an unresolvable conflict on v1beta2.conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() *builder.Phase1ObjV1Beta2Status { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.V1Beta2 + }, timeout).Should(Equal(objCopy.Status.V1Beta2)) + }) + + t.Run("should not return an error if there is an unresolvable conflict but the conditions is owned by the controller", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready clusterv1.condition and metav1.conditions to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready clusterv1.condition and metav1.conditions True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithOwnedConditions{Conditions: []clusterv1.ConditionType{clusterv1.ReadyCondition}}, WithOwnedV1Beta2Conditions{Conditions: []string{"Ready"}})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + readyV1Beta2Before := v1beta2conditions.Get(obj, "Ready") + readyV1Beta2After := v1beta2conditions.Get(objAfter, "Ready") + if readyV1Beta2Before == nil || readyV1Beta2After == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyV1Beta2Before).Match(*readyV1Beta2After) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should not return an error if there is an unresolvable conflict when force overwrite is enabled", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready clusterv1.condition and metav1.conditions to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready clusterv1.condition and metav1.conditions True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithForceOverwriteConditions{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + readyV1Beta2Before := v1beta2conditions.Get(obj, "Ready") + readyV1Beta2After := v1beta2conditions.Get(objAfter, "Ready") + if readyV1Beta2Before == nil || readyV1Beta2After == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyV1Beta2Before).Match(*readyV1Beta2After) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + }) + + t.Run("Should patch conditions on a v1beta2 object with both conditions and backward compatible conditions (phase 2)", func(t *testing.T) { + obj := &builder.Phase2Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: metav1.NamespaceDefault, + }, + } + + t.Run("should mark it ready and sort conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + // Adding Ready first + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition and back compatibility condition Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + // Adding Available as a second condition, but it should be sorted as first + t.Log("Creating a new patch helper") + patcher, err = NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Available=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Available", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Deprecated.V1Beta1.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Deprecated.V1Beta1.Conditions)) + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + if len(objAfter.Status.Conditions) != 2 { + return nil + } + if objAfter.Status.Conditions[0].Type != "Available" || objAfter.Status.Conditions[1].Type != "Ready" { + return nil + } + + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should mark it ready when passing Clusterv1ConditionsFieldPath and Metav1ConditionsFieldPath", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition and back compatibility condition Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, Clusterv1ConditionsFieldPath{"status", "deprecated", "v1beta1", "conditions"}, Metav1ConditionsFieldPath{"status", "conditions"})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Deprecated.V1Beta1.Conditions + }, timeout).Should(conditions.MatchConditions(obj.Status.Deprecated.V1Beta1.Conditions)) + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should recover if there is a resolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking condition and back compatibility condition Test=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("Test"), "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition and back compatibility condition Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testBackCompatibilityCopy := conditions.Get(objCopy, "Test") + testBackCompatibilityAfter := conditions.Get(objAfter, "Test") + if testBackCompatibilityCopy == nil || testBackCompatibilityAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testBackCompatibilityCopy).Match(*testBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + readyBackCompatibilityBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyBackCompatibilityAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBackCompatibilityBefore == nil || readyBackCompatibilityAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBackCompatibilityBefore).Match(*readyBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + testConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should recover if there is a resolvable conflict, incl. patch spec and status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking condition and back compatibility condition Test=False") + conditions.MarkFalse(objCopy, clusterv1.ConditionType("Test"), "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Changing the object spec, status, and marking condition and back compatibility condition Ready=True") + obj.Spec.Foo = "foo" + obj.Status.Bar = "bat" + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := obj.DeepCopy() + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testBackCompatibilityCopy := conditions.Get(objCopy, "Test") + testBackCompatibilityAfter := conditions.Get(objAfter, "Test") + if testBackCompatibilityCopy == nil || testBackCompatibilityAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*testBackCompatibilityCopy).Match(*testBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + readyBackCompatibilityBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyBackCompatibilityAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBackCompatibilityBefore == nil || readyBackCompatibilityAfter == nil { + return false + } + ok, err = conditions.MatchCondition(*readyBackCompatibilityBefore).Match(*readyBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + testConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo && + obj.Status.Bar == objAfter.Status.Bar + }, timeout).Should(BeTrue(), cmp.Diff(obj, objAfter)) + }) + + t.Run("should return an error if there is an unresolvable conflict on back compatibility conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready=True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() clusterv1.Conditions { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return clusterv1.Conditions{} + } + return objAfter.Status.Deprecated.V1Beta1.Conditions + }, timeout).Should(conditions.MatchConditions(objCopy.Status.Deprecated.V1Beta1.Conditions)) + }) + + t.Run("should return an error if there is an unresolvable conflict on conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(objCopy.Status.Conditions)) + }) + + t.Run("should not return an error if there is an unresolvable conflict but the conditions is owned by the controller", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition and back compatibility condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready condition and back compatibility condition True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithOwnedConditions{Conditions: []clusterv1.ConditionType{clusterv1.ReadyCondition}}, WithOwnedV1Beta2Conditions{Conditions: []string{"Ready"}})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBackCompatibilityBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyBackCompatibilityAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBackCompatibilityBefore == nil || readyBackCompatibilityAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*readyBackCompatibilityBefore).Match(*readyBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should not return an error if there is an unresolvable conflict when force overwrite is enabled", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition and back compatibility condition to be false") + conditions.MarkFalse(objCopy, clusterv1.ReadyCondition, "reason", clusterv1.ConditionSeverityInfo, "message") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready condition and back compatibility condition True") + conditions.MarkTrue(obj, clusterv1.ReadyCondition) + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithForceOverwriteConditions{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBackCompatibilityBefore := conditions.Get(obj, clusterv1.ReadyCondition) + readyBackCompatibilityAfter := conditions.Get(objAfter, clusterv1.ReadyCondition) + if readyBackCompatibilityBefore == nil || readyBackCompatibilityAfter == nil { + return false + } + ok, err := conditions.MatchCondition(*readyBackCompatibilityBefore).Match(*readyBackCompatibilityAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + }) + + t.Run("Should patch conditions on a v1beta2 object with conditions (phase 3)", func(t *testing.T) { + obj := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: metav1.NamespaceDefault, + }, + } + + t.Run("should mark it ready and sort conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + // Adding Ready first + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + // Adding Available as a second condition, but it should be sorted as first + t.Log("Creating a new patch helper") + patcher, err = NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Available=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Available", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + if len(objAfter.Status.Conditions) != 2 { + return nil + } + if objAfter.Status.Conditions[0].Type != "Available" || objAfter.Status.Conditions[1].Type != "Ready" { + return nil + } + + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should mark it ready when passing Metav1ConditionsFieldPath", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + err := env.Create(ctx, obj) + g.Expect(err).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, Metav1ConditionsFieldPath{"status", "conditions"})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(obj.Status.Conditions)) + }) + + t.Run("should recover if there is a resolvable conflict", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking condition Test=False") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := v1beta2conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should recover if there is a resolvable conflict, incl. patch spec and status", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking condition Test=False") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Test", Status: metav1.ConditionFalse, Reason: "reason", Message: "message", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Changing the object spec, status, and marking condition Ready=True") + obj.Spec.Foo = "foo" + obj.Status.Bar = "bat" + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).To(Succeed()) + + t.Log("Validating the object has been updated") + objAfter := obj.DeepCopy() + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + testConditionCopy := v1beta2conditions.Get(objCopy, "Test") + testConditionAfter := v1beta2conditions.Get(objAfter, "Test") + if testConditionCopy == nil || testConditionAfter == nil { + return false + } + ok, err := v1beta2conditions.MatchCondition(*testConditionCopy).Match(*testConditionAfter) + if err != nil || !ok { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err = v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return obj.Spec.Foo == objAfter.Spec.Foo && + obj.Status.Bar == objAfter.Status.Bar + }, timeout).Should(BeTrue(), cmp.Diff(obj, objAfter)) + }) + + t.Run("should return an error if there is an unresolvable conflict on conditions", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking condition Ready=True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj)).NotTo(Succeed()) + + t.Log("Validating the object has not been updated") + g.Eventually(func() []metav1.Condition { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return nil + } + return objAfter.Status.Conditions + }, timeout).Should(v1beta2conditions.MatchConditions(objCopy.Status.Conditions)) + }) + + t.Run("should not return an error if there is an unresolvable conflict but the conditions is owned by the controller", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready condition True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithOwnedV1Beta2Conditions{Conditions: []string{"Ready"}})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err := v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + + t.Run("should not return an error if there is an unresolvable conflict when force overwrite is enabled", func(t *testing.T) { + g := NewWithT(t) + + obj := obj.DeepCopy() + + t.Log("Creating the object") + g.Expect(env.Create(ctx, obj)).To(Succeed()) + defer func() { + g.Expect(env.Delete(ctx, obj)).To(Succeed()) + }() + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + t.Log("Checking that the object has been created") + g.Eventually(func() error { + obj := obj.DeepCopy() + return env.Get(ctx, key, obj) + }).Should(Succeed()) + + objCopy := obj.DeepCopy() + + t.Log("Marking a Ready condition to be false") + v1beta2conditions.Set(objCopy, metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotGood", LastTransitionTime: now}) + g.Expect(env.Status().Update(ctx, objCopy)).To(Succeed()) + + t.Log("Validating that the local object's resource version is behind") + g.Expect(obj.ResourceVersion).NotTo(Equal(objCopy.ResourceVersion)) + + t.Log("Creating a new patch helper") + patcher, err := NewHelper(obj, env) + g.Expect(err).ToNot(HaveOccurred()) + + t.Log("Marking Ready condition True") + v1beta2conditions.Set(obj, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood", LastTransitionTime: now}) + + t.Log("Patching the object") + g.Expect(patcher.Patch(ctx, obj, WithForceOverwriteConditions{})).To(Succeed()) + + t.Log("Validating the object has been updated") + g.Eventually(func() bool { + objAfter := obj.DeepCopy() + if err := env.Get(ctx, key, objAfter); err != nil { + return false + } + + readyBefore := v1beta2conditions.Get(obj, "Ready") + readyAfter := v1beta2conditions.Get(objAfter, "Ready") + if readyBefore == nil || readyAfter == nil { + return false + } + ok, err := v1beta2conditions.MatchCondition(*readyBefore).Match(*readyAfter) + if err != nil || !ok { + return false + } + + return true + }, timeout).Should(BeTrue()) + }) + }) +} diff --git a/util/deprecated/v1beta1/patch/suite_test.go b/util/deprecated/v1beta1/patch/suite_test.go new file mode 100644 index 000000000000..4c817b8a9690 --- /dev/null +++ b/util/deprecated/v1beta1/patch/suite_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "os" + "path/filepath" + "testing" + "time" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/internal/test/envtest" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +const ( + timeout = time.Second * 10 +) + +var ( + env *envtest.Environment + ctx = ctrl.SetupSignalHandler() +) + +func TestMain(m *testing.M) { + os.Exit(envtest.Run(ctx, envtest.RunInput{ + M: m, + SetupEnv: func(e *envtest.Environment) { env = e }, + AdditionalCRDDirectoryPaths: []string{ + filepath.Join("util", "deprecated", "v1beta1", "test", "builder", "crd"), + }, + AdditionalSchemeBuilder: runtime.NewSchemeBuilder( + builder.AddTransitionV1Beta2ToScheme, + clusterv1beta1.AddToScheme, + ), + })) +} diff --git a/util/deprecated/v1beta1/patch/utils.go b/util/deprecated/v1beta1/patch/utils.go new file mode 100644 index 000000000000..d16fa9734841 --- /dev/null +++ b/util/deprecated/v1beta1/patch/utils.go @@ -0,0 +1,210 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "reflect" + + "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" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +type patchType string + +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 +} + +// toUnstructured converts an object to Unstructured. +// We have to pass in a gvk as we can't rely on GVK being set in a runtime.Object. +func toUnstructured(obj runtime.Object, gvk schema.GroupVersionKind) (*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 + } + u := &unstructured.Unstructured{Object: rawMap} + u.SetGroupVersionKind(gvk) + + return u, 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, clusterv1ConditionsFieldPath, metav1ConditionsFieldPath []string) *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] + + preserve := false + switch focus { + case specPatch: + // For what we define as `spec` fields, we should preserve everything + // that's not `status`. + preserve = key != string(statusPatch) + case statusPatch: + // For status, only preserve the status fields. + preserve = key == string(focus) + } + + // Perform a shallow copy only for the keys we're interested in, + // or the ones that should be always preserved (like metadata). + if preserve || preserveUnstructuredKeys[key] { + res.Object[key] = value + } + } + + // If we've determined that new or old condition must be set, + // when dealing with the status patch, remove corresponding sub-fields from the object. + // NOTE: Removing conditions sub-fields 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. + if focus == statusPatch { + if len(clusterv1ConditionsFieldPath) > 0 { + unstructured.RemoveNestedField(res.Object, clusterv1ConditionsFieldPath...) + } + if len(metav1ConditionsFieldPath) > 0 { + unstructured.RemoveNestedField(res.Object, metav1ConditionsFieldPath...) + } + } + + return res +} + +var ( + clusterv1ConditionsType = reflect.TypeOf(clusterv1.Conditions{}) + metav1ConditionsType = reflect.TypeOf([]metav1.Condition{}) +) + +func identifyConditionsFieldsPath(obj runtime.Object) ([]string, []string, error) { + if obj == nil { + return nil, nil, errors.New("cannot identify conditions on a nil object") + } + + ptr := reflect.ValueOf(obj) + if ptr.Kind() != reflect.Pointer { + return nil, nil, errors.New("cannot identify conditions on a object that is not a pointer") + } + + elem := ptr.Elem() + if !elem.IsValid() { + return nil, nil, errors.New("obj must be a valid value (non zero value of its type)") + } + + statusField := elem.FieldByName("Status") + if statusField == (reflect.Value{}) { + return nil, nil, nil + } + + // NOTE: Given that we allow providers to migrate at different speed, it is required to support objects at the different stage of the transition from clusterv1.conditions to metav1.conditions. + // In order to handle this, it is required to identify where conditions are supported (metav1 or clusterv1 and where they are located. + + var metav1ConditionsFields []string + var clusterv1ConditionsFields []string + + if v1beta2Field := statusField.FieldByName("V1Beta2"); v1beta2Field != (reflect.Value{}) { + if v1beta2Field.Kind() != reflect.Pointer { + return nil, nil, errors.New("obj.status.v1beta2 must be a pointer") + } + + v1beta2Elem := v1beta2Field.Elem() + if !v1beta2Elem.IsValid() { + // If the field is a zero value of its type, we can't further investigate type struct. + // We assume the type is implemented according to transition guidelines + metav1ConditionsFields = []string{"status", "v1beta2", "conditions"} + } else { + if conditionField := v1beta2Elem.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + metav1ConditionsFields = []string{"status", "v1beta2", "conditions"} + } + } + } + + if conditionField := statusField.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + if conditionField.Type() == metav1ConditionsType { + metav1ConditionsFields = []string{"status", "conditions"} + } + if conditionField.Type() == clusterv1ConditionsType { + clusterv1ConditionsFields = []string{"status", "conditions"} + } + } + + if deprecatedField := statusField.FieldByName("Deprecated"); deprecatedField != (reflect.Value{}) { + if deprecatedField.Kind() != reflect.Pointer { + return nil, nil, errors.New("obj.status.deprecated must be a pointer") + } + + deprecatedElem := deprecatedField.Elem() + if !deprecatedElem.IsValid() { + // If the field is a zero value of its type, we can't further investigate type struct. + // We assume the type is implemented according to transition guidelines + clusterv1ConditionsFields = []string{"status", "deprecated", "v1beta1", "conditions"} + } else { + if v1Beta1Field := deprecatedElem.FieldByName("V1Beta1"); deprecatedField != (reflect.Value{}) { + if v1Beta1Field.Kind() != reflect.Pointer { + return nil, nil, errors.New("obj.status.deprecated.v1beta1 must be a pointer") + } + + v1Beta1Elem := v1Beta1Field.Elem() + if !v1Beta1Elem.IsValid() { + // If the field is a zero value of its type, we can't further investigate type struct. + // We assume the type is implemented according to transition guidelines + clusterv1ConditionsFields = []string{"status", "deprecated", "v1beta1", "conditions"} + } else { + if conditionField := v1Beta1Elem.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + clusterv1ConditionsFields = []string{"status", "deprecated", "v1beta1", "conditions"} + } + } + } + } + } + + return metav1ConditionsFields, clusterv1ConditionsFields, nil +} diff --git a/util/deprecated/v1beta1/patch/utils_test.go b/util/deprecated/v1beta1/patch/utils_test.go new file mode 100644 index 000000000000..de0ae1be39c0 --- /dev/null +++ b/util/deprecated/v1beta1/patch/utils_test.go @@ -0,0 +1,301 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package patch + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestToUnstructured(t *testing.T) { + t.Run("with a typed object", func(t *testing.T) { + g := NewWithT(t) + // Test with a typed object. + obj := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-1", + Namespace: "namespace-1", + }, + Spec: clusterv1.ClusterSpec{ + Paused: true, + }, + } + gvk := schema.GroupVersionKind{ + Group: clusterv1.GroupVersion.Group, + Kind: "Cluster", + Version: clusterv1.GroupVersion.Version, + } + newObj, err := toUnstructured(obj, gvk) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(newObj.GetName()).To(Equal(obj.Name)) + g.Expect(newObj.GetNamespace()).To(Equal(obj.Namespace)) + g.Expect(newObj.GetAPIVersion()).To(Equal(clusterv1.GroupVersion.String())) + g.Expect(newObj.GetKind()).To(Equal("Cluster")) + + // 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()) + g.Expect(obj.Spec.Paused).To(BeTrue()) + }) + + 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", + "kind": "TestKind", + "metadata": map[string]interface{}{ + "name": "test-1", + "namespace": "namespace-1", + }, + "spec": map[string]interface{}{ + "paused": true, + }, + }, + } + gvk := schema.GroupVersionKind{ + Group: "test.x.y.z", + Kind: "TestKind", + Version: "v1", + } + + newObj, err := toUnstructured(obj, gvk) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(newObj.GetName()).To(Equal(obj.GetName())) + g.Expect(newObj.GetNamespace()).To(Equal(obj.GetNamespace())) + g.Expect(newObj.GetAPIVersion()).To(Equal("test.x.y.z/v1")) + g.Expect(newObj.GetKind()).To(Equal("TestKind")) + + // 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, nil, []string{"status", "conditions"}) + + // 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, nil, []string{"status", "conditions"}) + + // 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(BeTrue()) + 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, nil, nil) + + // 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()) + }) +} + +func TestIdentifyConditionsFieldsPath(t *testing.T) { + t.Run("v1beta1 object with conditions (phase 0)", func(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase0Obj{} + metav1ConditionsFields, clusterv1ConditionsFields, err := identifyConditionsFieldsPath(obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(metav1ConditionsFields).To(BeEmpty()) + g.Expect(clusterv1ConditionsFields).To(Equal([]string{"status", "conditions"})) + }) + t.Run("v1beta1 object with both clusterv1.conditions and metav1.conditions (phase 1)", func(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + obj runtime.Object + }{ + {obj: &builder.Phase1Obj{}}, + {obj: &builder.Phase1Obj{Status: builder.Phase1ObjStatus{V1Beta2: nil}}}, + {obj: &builder.Phase1Obj{Status: builder.Phase1ObjStatus{V1Beta2: &builder.Phase1ObjV1Beta2Status{Conditions: nil}}}}, + } + for _, tt := range tests { + metav1ConditionsFields, clusterv1ConditionsFields, err := identifyConditionsFieldsPath(tt.obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(metav1ConditionsFields).To(Equal([]string{"status", "v1beta2", "conditions"})) + g.Expect(clusterv1ConditionsFields).To(Equal([]string{"status", "conditions"})) + } + }) + t.Run("v1beta2 object with both clusterv1.conditions and metav1.conditions conditions (phase 2)", func(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + obj runtime.Object + }{ + {obj: &builder.Phase2Obj{}}, + {obj: &builder.Phase2Obj{Status: builder.Phase2ObjStatus{Deprecated: nil}}}, + {obj: &builder.Phase2Obj{Status: builder.Phase2ObjStatus{Deprecated: &builder.Phase2ObjDeprecatedStatus{V1Beta1: nil}}}}, + {obj: &builder.Phase2Obj{Status: builder.Phase2ObjStatus{Deprecated: &builder.Phase2ObjDeprecatedStatus{V1Beta1: &builder.Phase2ObjDeprecatedV1Beta1Status{Conditions: nil}}}}}, + } + for _, tt := range tests { + metav1ConditionsFields, clusterv1ConditionsFields, err := identifyConditionsFieldsPath(tt.obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(metav1ConditionsFields).To(Equal([]string{"status", "conditions"})) + g.Expect(clusterv1ConditionsFields).To(Equal([]string{"status", "deprecated", "v1beta1", "conditions"})) + } + }) + t.Run("v1beta2 object with metav1.conditions (phase 3)", func(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase3Obj{} + metav1ConditionsFields, clusterv1ConditionsFields, err := identifyConditionsFieldsPath(obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(metav1ConditionsFields).To(Equal([]string{"status", "conditions"})) + g.Expect(clusterv1ConditionsFields).To(BeEmpty()) + }) +} diff --git a/util/deprecated/v1beta1/paused/paused.go b/util/deprecated/v1beta1/paused/paused.go new file mode 100644 index 000000000000..4e81aaa8b19e --- /dev/null +++ b/util/deprecated/v1beta1/paused/paused.go @@ -0,0 +1,123 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package paused implements paused helper functions. +package paused + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta2" + "sigs.k8s.io/cluster-api/util/annotations" + v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/patch" +) + +// ConditionSetter combines the client.Object and Setter interface. +type ConditionSetter interface { + v1beta2conditions.Setter + client.Object +} + +// EnsurePausedCondition sets the paused condition on the object and returns if it should be considered as paused. +func EnsurePausedCondition(ctx context.Context, c client.Client, cluster *clusterv1.Cluster, v1beta1Obj ConditionSetter) (isPaused bool, requeue bool, err error) { + oldCondition := v1beta2conditions.Get(v1beta1Obj, clusterv1beta1.PausedV1Beta2Condition) + newCondition := pausedCondition(c.Scheme(), cluster, v1beta1Obj, clusterv1beta1.PausedV1Beta2Condition) + + isPaused = newCondition.Status == metav1.ConditionTrue + pausedStatusChanged := oldCondition == nil || oldCondition.Status != newCondition.Status + + log := ctrl.LoggerFrom(ctx) + + switch { + case pausedStatusChanged && isPaused: + log.V(4).Info("Pausing reconciliation for this object", "reason", newCondition.Message) + case pausedStatusChanged && !isPaused: + log.V(4).Info("Unpausing reconciliation for this object") + case !pausedStatusChanged && isPaused: + log.V(6).Info("Reconciliation is paused for this object", "reason", newCondition.Message) + } + + if oldCondition != nil { + // Return early if the paused condition did not change at all. + if v1beta2conditions.HasSameState(oldCondition, &newCondition) { + return isPaused, false, nil + } + + // Set condition and return early if only observed generation changed and obj is not paused. + // In this case we want to avoid the additional reconcile that we would get by requeueing. + if v1beta2conditions.HasSameStateExceptObservedGeneration(oldCondition, &newCondition) && !isPaused { + v1beta2conditions.Set(v1beta1Obj, newCondition) + return isPaused, false, nil + } + } + + patchHelper, err := patch.NewHelper(v1beta1Obj, c) + if err != nil { + return isPaused, false, err + } + + v1beta2conditions.Set(v1beta1Obj, newCondition) + + if err := patchHelper.Patch(ctx, v1beta1Obj, patch.WithOwnedV1Beta2Conditions{Conditions: []string{ + clusterv1beta1.PausedV1Beta2Condition, + }}); err != nil { + return isPaused, false, err + } + + return isPaused, true, nil +} + +// pausedCondition sets the paused condition on the object and returns if it should be considered as paused. +func pausedCondition(scheme *runtime.Scheme, cluster *clusterv1.Cluster, obj ConditionSetter, targetConditionType string) metav1.Condition { + if (cluster != nil && cluster.Spec.Paused) || annotations.HasPaused(obj) { + var messages []string + if cluster != nil && cluster.Spec.Paused { + messages = append(messages, "Cluster spec.paused is set to true") + } + if annotations.HasPaused(obj) { + kind := "Object" + if gvk, err := apiutil.GVKForObject(obj, scheme); err == nil { + kind = gvk.Kind + } + messages = append(messages, fmt.Sprintf("%s has the cluster.x-k8s.io/paused annotation", kind)) + } + + return metav1.Condition{ + Type: targetConditionType, + Status: metav1.ConditionTrue, + Reason: clusterv1beta1.PausedV1Beta2Reason, + Message: strings.Join(messages, ", "), + ObservedGeneration: obj.GetGeneration(), + } + } + + return metav1.Condition{ + Type: targetConditionType, + Status: metav1.ConditionFalse, + Reason: clusterv1beta1.NotPausedV1Beta2Reason, + ObservedGeneration: obj.GetGeneration(), + } +} diff --git a/util/deprecated/v1beta1/paused/paused_test.go b/util/deprecated/v1beta1/paused/paused_test.go new file mode 100644 index 000000000000..7a9802b6f074 --- /dev/null +++ b/util/deprecated/v1beta1/paused/paused_test.go @@ -0,0 +1,150 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package paused implements paused helper functions. +package paused + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + clusterv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta2" + v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2" + "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/test/builder" +) + +func TestEnsurePausedCondition(t *testing.T) { + g := NewWithT(t) + + scheme := runtime.NewScheme() + g.Expect(builder.AddTransitionV1Beta2ToScheme(scheme)).To(Succeed()) + g.Expect(clusterv1.AddToScheme(scheme)).To(Succeed()) + + // Cluster Case 1: unpaused + normalCluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-cluster", + Namespace: "default", + }, + } + + // Cluster Case 2: paused + pausedCluster := normalCluster.DeepCopy() + pausedCluster.Spec.Paused = true + + // Object case 1: unpaused + obj := &builder.Phase1Obj{ObjectMeta: metav1.ObjectMeta{ + Name: "some-object", + Namespace: "default", + Generation: 1, + }} + + // Object case 2: paused + pausedObj := obj.DeepCopy() + pausedObj.SetAnnotations(map[string]string{clusterv1beta1.PausedAnnotation: ""}) + + tests := []struct { + name string + cluster *clusterv1.Cluster + object ConditionSetter + wantIsPaused bool + wantRequeueAfterGenerationChange bool + }{ + { + name: "unpaused cluster and unpaused object", + cluster: normalCluster.DeepCopy(), + object: obj.DeepCopy(), + wantIsPaused: false, + wantRequeueAfterGenerationChange: false, // We don't want a requeue in this case to avoid additional reconciles. + }, + { + name: "paused cluster and unpaused object", + cluster: pausedCluster.DeepCopy(), + object: obj.DeepCopy(), + wantIsPaused: true, + wantRequeueAfterGenerationChange: true, + }, + { + name: "unpaused cluster and paused object", + cluster: normalCluster.DeepCopy(), + object: pausedObj.DeepCopy(), + wantIsPaused: true, + wantRequeueAfterGenerationChange: true, + }, + { + name: "paused cluster and paused object", + cluster: pausedCluster.DeepCopy(), + object: pausedObj.DeepCopy(), + wantIsPaused: true, + wantRequeueAfterGenerationChange: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1.Cluster{}, &builder.Phase1Obj{}). + WithObjects(tt.object, tt.cluster).Build() + + g.Expect(c.Get(ctx, client.ObjectKeyFromObject(tt.object), tt.object)).To(Succeed()) + + // The first run should set the condition. + gotIsPaused, gotRequeue, err := EnsurePausedCondition(ctx, c, tt.cluster, tt.object) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(gotRequeue).To(BeTrue(), "The first reconcile should return requeue=true because it set the Paused condition") + g.Expect(gotIsPaused).To(Equal(tt.wantIsPaused)) + assertCondition(g, tt.object, tt.wantIsPaused) + + // The second reconcile should be a no-op. + gotIsPaused, gotRequeue, err = EnsurePausedCondition(ctx, c, tt.cluster, tt.object) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(gotRequeue).To(BeFalse(), "The second reconcile should return requeue=false as the Paused condition was not changed") + g.Expect(gotIsPaused).To(Equal(tt.wantIsPaused)) + assertCondition(g, tt.object, tt.wantIsPaused) + + // The third reconcile reconciles a generation change, condition should be updated and requeue=true + // should only be returned if the object is paused. + tt.object.SetGeneration(tt.object.GetGeneration() + 1) + gotIsPaused, gotRequeue, err = EnsurePausedCondition(ctx, c, tt.cluster, tt.object) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(gotRequeue).To(Equal(tt.wantRequeueAfterGenerationChange)) + g.Expect(gotIsPaused).To(Equal(tt.wantIsPaused)) + assertCondition(g, tt.object, tt.wantIsPaused) + }) + } +} + +func assertCondition(g Gomega, object ConditionSetter, wantIsPaused bool) { + condition := v1beta2conditions.Get(object, clusterv1beta1.PausedV1Beta2Condition) + g.Expect(condition.ObservedGeneration).To(Equal(object.GetGeneration())) + if wantIsPaused { + g.Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(condition.Reason).To(Equal(clusterv1beta1.PausedV1Beta2Reason)) + g.Expect(condition.Message).ToNot(BeEmpty()) + } else { + g.Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(condition.Reason).To(Equal(clusterv1beta1.NotPausedV1Beta2Reason)) + g.Expect(condition.Message).To(BeEmpty()) + } +} diff --git a/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase0obj.yaml b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase0obj.yaml new file mode 100644 index 000000000000..5a9a090ba162 --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase0obj.yaml @@ -0,0 +1,110 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: phase0obj.deprecatedtest.cluster.x-k8s.io +spec: + group: deprecatedtest.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase0Obj + listKind: Phase0ObjList + plural: phase0obj + singular: phase0obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase0Obj defines an object with clusterv1.Conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase0ObjSpec defines the spec of a Phase0Obj. + properties: + foo: + type: string + type: object + status: + description: Phase0ObjStatus defines the status of a Phase0Obj. + properties: + bar: + type: string + conditions: + description: Conditions provide observations of the operational state + of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This field may be empty. + maxLength: 10240 + minLength: 1 + type: string + reason: + description: |- + reason is the reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + maxLength: 256 + minLength: 1 + type: string + severity: + description: |- + severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + maxLength: 32 + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + maxLength: 256 + minLength: 1 + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase1obj.yaml b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase1obj.yaml new file mode 100644 index 000000000000..7f210f2768fa --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase1obj.yaml @@ -0,0 +1,182 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: phase1obj.deprecatedtest.cluster.x-k8s.io +spec: + group: deprecatedtest.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase1Obj + listKind: Phase1ObjList + plural: phase1obj + singular: phase1obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase1Obj defines an object with conditions and experimental + conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase1ObjSpec defines the spec of a Phase1Obj. + properties: + foo: + type: string + type: object + status: + description: Phase1ObjStatus defines the status of a Phase1Obj. + properties: + bar: + type: string + conditions: + description: Conditions provide observations of the operational state + of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This field may be empty. + maxLength: 10240 + minLength: 1 + type: string + reason: + description: |- + reason is the reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + maxLength: 256 + minLength: 1 + type: string + severity: + description: |- + severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + maxLength: 32 + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + maxLength: 256 + minLength: 1 + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + observedGeneration: + description: observedGeneration reflects the generation of the most + recently observed MachineSet. + format: int64 + type: integer + v1beta2: + description: Phase1ObjV1Beta2Status defines the status.V1Beta2 of + a Phase1Obj. + properties: + conditions: + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 32 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase2obj.yaml b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase2obj.yaml new file mode 100644 index 000000000000..17a334ba0674 --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase2obj.yaml @@ -0,0 +1,182 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: phase2obj.deprecatedtest.cluster.x-k8s.io +spec: + group: deprecatedtest.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase2Obj + listKind: Phase2ObjList + plural: phase2obj + singular: phase2obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase2Obj defines an object with conditions and back compatibility + conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase2ObjSpec defines the spec of a Phase2Obj. + properties: + foo: + type: string + type: object + status: + description: Phase2ObjStatus defines the status of a Phase2Obj. + properties: + bar: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 32 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + deprecated: + description: Phase2ObjDeprecatedStatus defines the status.Deprecated + of a Phase2Obj. + properties: + v1beta1: + description: Phase2ObjDeprecatedV1Beta1Status defines the status.Deprecated.V1Beta2 + of a Phase2Obj. + properties: + conditions: + description: Conditions provide observations of the operational + state of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster + API resource operational state. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This field may be empty. + maxLength: 10240 + minLength: 1 + type: string + reason: + description: |- + reason is the reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + maxLength: 256 + minLength: 1 + type: string + severity: + description: |- + severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + maxLength: 32 + type: string + status: + description: status of the condition, one of True, False, + Unknown. + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + maxLength: 256 + minLength: 1 + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + type: object + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase3obj.yaml b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase3obj.yaml new file mode 100644 index 000000000000..17433def5f13 --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/crd/deprecatedtest.cluster.x-k8s.io_phase3obj.yaml @@ -0,0 +1,117 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: phase3obj.deprecatedtest.cluster.x-k8s.io +spec: + group: deprecatedtest.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase3Obj + listKind: Phase3ObjList + plural: phase3obj + singular: phase3obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase3Obj defines an object with metav1.conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase3ObjSpec defines the spec of a Phase3Obj. + properties: + foo: + type: string + type: object + status: + description: Phase3ObjStatus defines the status of a Phase3Obj. + properties: + bar: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 32 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/util/deprecated/v1beta1/test/builder/doc.go b/util/deprecated/v1beta1/test/builder/doc.go new file mode 100644 index 000000000000..df0d5802655a --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package builder implements builder and CRDs for creating API objects for testing. +// +// Deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details. +// +// +kubebuilder:object:generate=true +// +groupName=deprecatedtest.cluster.x-k8s.io +// +versionName=v1alpha1 +package builder diff --git a/util/deprecated/v1beta1/test/builder/v1beta2_transition.go b/util/deprecated/v1beta1/test/builder/v1beta2_transition.go new file mode 100644 index 000000000000..af9f918c7ff3 --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/v1beta2_transition.go @@ -0,0 +1,305 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package builder + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// This files provide types to validate transition from clusterv1.Conditions in v1Beta1 API to the metav1.Conditions in the v1Beta2 API. +// Please refer to util/conditions/v1beta2/doc.go for more context. + +var ( + // TestGroupVersion is group version used for test CRDs used for validating the v1beta2 transition. + TestGroupVersion = schema.GroupVersion{Group: "deprecatedtest.cluster.x-k8s.io", Version: "v1alpha1"} + + // schemeBuilder is used to add go types to the GroupVersionKind scheme. + schemeBuilder = runtime.NewSchemeBuilder(addTransitionV1beta2Types) + + // AddTransitionV1Beta2ToScheme adds the types for validating the transition to v1Beta2 in this group-version to the given scheme. + AddTransitionV1Beta2ToScheme = schemeBuilder.AddToScheme +) + +func addTransitionV1beta2Types(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(TestGroupVersion, + &Phase0Obj{}, &Phase0ObjList{}, + &Phase1Obj{}, &Phase1ObjList{}, + &Phase2Obj{}, &Phase2ObjList{}, + &Phase3Obj{}, &Phase3ObjList{}, + ) + metav1.AddToGroupVersion(scheme, TestGroupVersion) + return nil +} + +// Phase0ObjList is a list of Phase0Obj. +// +kubebuilder:object:root=true +type Phase0ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase0Obj `json:"items"` +} + +// Phase0Obj defines an object with clusterv1.Conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase0obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase0Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase0ObjSpec `json:"spec,omitempty"` + Status Phase0ObjStatus `json:"status,omitempty"` +} + +// Phase0ObjSpec defines the spec of a Phase0Obj. +type Phase0ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase0ObjStatus defines the status of a Phase0Obj. +type Phase0ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase0Obj) GetConditions() clusterv1.Conditions { + return o.Status.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase0Obj) SetConditions(conditions clusterv1.Conditions) { + o.Status.Conditions = conditions +} + +// Phase1ObjList is a list of Phase1Obj. +// +kubebuilder:object:root=true +type Phase1ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase1Obj `json:"items"` +} + +// Phase1Obj defines an object with conditions and experimental conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase1obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase1Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase1ObjSpec `json:"spec,omitempty"` + Status Phase1ObjStatus `json:"status,omitempty"` +} + +// Phase1ObjSpec defines the spec of a Phase1Obj. +type Phase1ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase1ObjStatus defines the status of a Phase1Obj. +type Phase1ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` + + // +optional + V1Beta2 *Phase1ObjV1Beta2Status `json:"v1beta2,omitempty"` + + // observedGeneration reflects the generation of the most recently observed MachineSet. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// Phase1ObjV1Beta2Status defines the status.V1Beta2 of a Phase1Obj. +type Phase1ObjV1Beta2Status struct { + + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=32 + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase1Obj) GetConditions() clusterv1.Conditions { + return o.Status.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase1Obj) SetConditions(conditions clusterv1.Conditions) { + o.Status.Conditions = conditions +} + +// GetV1Beta2Conditions returns the set of conditions for this object. +func (o *Phase1Obj) GetV1Beta2Conditions() []metav1.Condition { + if o.Status.V1Beta2 == nil { + return nil + } + return o.Status.V1Beta2.Conditions +} + +// SetV1Beta2Conditions sets conditions for an API object. +func (o *Phase1Obj) SetV1Beta2Conditions(conditions []metav1.Condition) { + if o.Status.V1Beta2 == nil { + o.Status.V1Beta2 = &Phase1ObjV1Beta2Status{} + } + o.Status.V1Beta2.Conditions = conditions +} + +// Phase2ObjList is a list of Phase2Obj. +// +kubebuilder:object:root=true +type Phase2ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase2Obj `json:"items"` +} + +// Phase2Obj defines an object with conditions and back compatibility conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase2obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase2Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase2ObjSpec `json:"spec,omitempty"` + Status Phase2ObjStatus `json:"status,omitempty"` +} + +// Phase2ObjSpec defines the spec of a Phase2Obj. +type Phase2ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase2ObjStatus defines the status of a Phase2Obj. +type Phase2ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=32 + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // +optional + Deprecated *Phase2ObjDeprecatedStatus `json:"deprecated,omitempty"` +} + +// Phase2ObjDeprecatedStatus defines the status.Deprecated of a Phase2Obj. +type Phase2ObjDeprecatedStatus struct { + + // +optional + V1Beta1 *Phase2ObjDeprecatedV1Beta1Status `json:"v1beta1,omitempty"` +} + +// Phase2ObjDeprecatedV1Beta1Status defines the status.Deprecated.V1Beta2 of a Phase2Obj. +type Phase2ObjDeprecatedV1Beta1Status struct { + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase2Obj) GetConditions() clusterv1.Conditions { + if o.Status.Deprecated == nil || o.Status.Deprecated.V1Beta1 == nil { + return nil + } + return o.Status.Deprecated.V1Beta1.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase2Obj) SetConditions(conditions clusterv1.Conditions) { + if o.Status.Deprecated == nil { + o.Status.Deprecated = &Phase2ObjDeprecatedStatus{V1Beta1: &Phase2ObjDeprecatedV1Beta1Status{}} + } + if o.Status.Deprecated.V1Beta1 == nil { + o.Status.Deprecated.V1Beta1 = &Phase2ObjDeprecatedV1Beta1Status{} + } + o.Status.Deprecated.V1Beta1.Conditions = conditions +} + +// GetV1Beta2Conditions returns the set of conditions for this object. +func (o *Phase2Obj) GetV1Beta2Conditions() []metav1.Condition { + return o.Status.Conditions +} + +// SetV1Beta2Conditions sets conditions for an API object. +func (o *Phase2Obj) SetV1Beta2Conditions(conditions []metav1.Condition) { + o.Status.Conditions = conditions +} + +// Phase3ObjList is a list of Phase3Obj. +// +kubebuilder:object:root=true +type Phase3ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase3Obj `json:"items"` +} + +// Phase3Obj defines an object with metav1.conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase3obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase3Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase3ObjSpec `json:"spec,omitempty"` + Status Phase3ObjStatus `json:"status,omitempty"` +} + +// Phase3ObjSpec defines the spec of a Phase3Obj. +type Phase3ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase3ObjStatus defines the status of a Phase3Obj. +type Phase3ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=32 + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// GetV1Beta2Conditions returns the set of conditions for this object. +func (o *Phase3Obj) GetV1Beta2Conditions() []metav1.Condition { + return o.Status.Conditions +} + +// SetV1Beta2Conditions sets conditions for an API object. +func (o *Phase3Obj) SetV1Beta2Conditions(conditions []metav1.Condition) { + o.Status.Conditions = conditions +} diff --git a/util/deprecated/v1beta1/test/builder/zz_generated.deepcopy.go b/util/deprecated/v1beta1/test/builder/zz_generated.deepcopy.go new file mode 100644 index 000000000000..25d326cff603 --- /dev/null +++ b/util/deprecated/v1beta1/test/builder/zz_generated.deepcopy.go @@ -0,0 +1,485 @@ +//go:build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package builder + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0Obj) DeepCopyInto(out *Phase0Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0Obj. +func (in *Phase0Obj) DeepCopy() *Phase0Obj { + if in == nil { + return nil + } + out := new(Phase0Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase0Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjList) DeepCopyInto(out *Phase0ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase0Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjList. +func (in *Phase0ObjList) DeepCopy() *Phase0ObjList { + if in == nil { + return nil + } + out := new(Phase0ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase0ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjSpec) DeepCopyInto(out *Phase0ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjSpec. +func (in *Phase0ObjSpec) DeepCopy() *Phase0ObjSpec { + if in == nil { + return nil + } + out := new(Phase0ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjStatus) DeepCopyInto(out *Phase0ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjStatus. +func (in *Phase0ObjStatus) DeepCopy() *Phase0ObjStatus { + if in == nil { + return nil + } + out := new(Phase0ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1Obj) DeepCopyInto(out *Phase1Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1Obj. +func (in *Phase1Obj) DeepCopy() *Phase1Obj { + if in == nil { + return nil + } + out := new(Phase1Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase1Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjList) DeepCopyInto(out *Phase1ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase1Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjList. +func (in *Phase1ObjList) DeepCopy() *Phase1ObjList { + if in == nil { + return nil + } + out := new(Phase1ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase1ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjSpec) DeepCopyInto(out *Phase1ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjSpec. +func (in *Phase1ObjSpec) DeepCopy() *Phase1ObjSpec { + if in == nil { + return nil + } + out := new(Phase1ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjStatus) DeepCopyInto(out *Phase1ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.V1Beta2 != nil { + in, out := &in.V1Beta2, &out.V1Beta2 + *out = new(Phase1ObjV1Beta2Status) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjStatus. +func (in *Phase1ObjStatus) DeepCopy() *Phase1ObjStatus { + if in == nil { + return nil + } + out := new(Phase1ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjV1Beta2Status) DeepCopyInto(out *Phase1ObjV1Beta2Status) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjV1Beta2Status. +func (in *Phase1ObjV1Beta2Status) DeepCopy() *Phase1ObjV1Beta2Status { + if in == nil { + return nil + } + out := new(Phase1ObjV1Beta2Status) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2Obj) DeepCopyInto(out *Phase2Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2Obj. +func (in *Phase2Obj) DeepCopy() *Phase2Obj { + if in == nil { + return nil + } + out := new(Phase2Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase2Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjDeprecatedStatus) DeepCopyInto(out *Phase2ObjDeprecatedStatus) { + *out = *in + if in.V1Beta1 != nil { + in, out := &in.V1Beta1, &out.V1Beta1 + *out = new(Phase2ObjDeprecatedV1Beta1Status) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjDeprecatedStatus. +func (in *Phase2ObjDeprecatedStatus) DeepCopy() *Phase2ObjDeprecatedStatus { + if in == nil { + return nil + } + out := new(Phase2ObjDeprecatedStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjDeprecatedV1Beta1Status) DeepCopyInto(out *Phase2ObjDeprecatedV1Beta1Status) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjDeprecatedV1Beta1Status. +func (in *Phase2ObjDeprecatedV1Beta1Status) DeepCopy() *Phase2ObjDeprecatedV1Beta1Status { + if in == nil { + return nil + } + out := new(Phase2ObjDeprecatedV1Beta1Status) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjList) DeepCopyInto(out *Phase2ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase2Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjList. +func (in *Phase2ObjList) DeepCopy() *Phase2ObjList { + if in == nil { + return nil + } + out := new(Phase2ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase2ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjSpec) DeepCopyInto(out *Phase2ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjSpec. +func (in *Phase2ObjSpec) DeepCopy() *Phase2ObjSpec { + if in == nil { + return nil + } + out := new(Phase2ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjStatus) DeepCopyInto(out *Phase2ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Deprecated != nil { + in, out := &in.Deprecated, &out.Deprecated + *out = new(Phase2ObjDeprecatedStatus) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjStatus. +func (in *Phase2ObjStatus) DeepCopy() *Phase2ObjStatus { + if in == nil { + return nil + } + out := new(Phase2ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3Obj) DeepCopyInto(out *Phase3Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3Obj. +func (in *Phase3Obj) DeepCopy() *Phase3Obj { + if in == nil { + return nil + } + out := new(Phase3Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase3Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjList) DeepCopyInto(out *Phase3ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase3Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjList. +func (in *Phase3ObjList) DeepCopy() *Phase3ObjList { + if in == nil { + return nil + } + out := new(Phase3ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase3ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjSpec) DeepCopyInto(out *Phase3ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjSpec. +func (in *Phase3ObjSpec) DeepCopy() *Phase3ObjSpec { + if in == nil { + return nil + } + out := new(Phase3ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjStatus) DeepCopyInto(out *Phase3ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjStatus. +func (in *Phase3ObjStatus) DeepCopy() *Phase3ObjStatus { + if in == nil { + return nil + } + out := new(Phase3ObjStatus) + in.DeepCopyInto(out) + return out +} diff --git a/util/patch/suite_test.go b/util/patch/suite_test.go index 2535fe65361c..77cd314cc45e 100644 --- a/util/patch/suite_test.go +++ b/util/patch/suite_test.go @@ -18,12 +18,15 @@ package patch import ( "os" + "path/filepath" "testing" "time" + "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/cluster-api/internal/test/envtest" + "sigs.k8s.io/cluster-api/util/test/builder" ) const ( @@ -39,5 +42,11 @@ func TestMain(m *testing.M) { os.Exit(envtest.Run(ctx, envtest.RunInput{ M: m, SetupEnv: func(e *envtest.Environment) { env = e }, + AdditionalCRDDirectoryPaths: []string{ + filepath.Join("util", "test", "builder", "crd"), + }, + AdditionalSchemeBuilder: runtime.NewSchemeBuilder( + builder.AddTransitionV1Beta2ToScheme, + ), })) }