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