diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb03898a2..7041db6ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - Add a new option to set the minimum log level that triggers stack trace generation in logs (`--zap-stacktrace-level`) ([#2319](https://github.com/operator-framework/operator-sdk/pull/2319)) +- Added `pkg/status` with several new types and interfaces that can be used in `Status` structs to simplify handling of [status conditions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties). ([#1143](https://github.com/operator-framework/operator-sdk/pull/1143)) ### Changed diff --git a/doc/user-guide.md b/doc/user-guide.md index 35164dcbed..045df2cee5 100644 --- a/doc/user-guide.md +++ b/doc/user-guide.md @@ -428,6 +428,35 @@ $ kubectl delete -f deploy/service_account.yaml ## Advanced Topics +### Manage CR status conditions + +An often-used pattern is to include `Conditions` in the status of custom resources. Conditions represent the latest available observations of an object's state (see the [Kubernetes API conventionsdocumentation][typical-status-properties] for more information). + +The `Conditions` field added to the `MemcachedStatus` struct simplifies the management of your CR's conditions. It: +- Enables callers to add and remove conditions. +- Ensures that there are no duplicates. +- Sorts the conditions deterministically to avoid unnecessary repeated reconciliations. +- Automatically handles the each condition's `LastTransitionTime`. +- Provides helper methods to make it easy to determine the state of a condition. + +To use conditions in your custom resource, add a Conditions field to the Status struct in `_types.go`: + +```Go +import ( + "github.com/operator-framework/operator-sdk/pkg/status" +) + +type MyAppStatus struct { + // Conditions represent the latest available observations of an object's state + Conditions status.Conditions `json:"conditions"` +} +``` + + +Then, in your controller, you can use `Conditions` methods to make it easier to set and remove conditions or check their current values. + ### Adding 3rd Party Resources To Your Operator The operator's Manager supports the Core Kubernetes resource types as found in the client-go [scheme][scheme_package] package and will also register the schemes of all custom resource types defined in your project under `pkg/apis`. @@ -737,3 +766,4 @@ When the operator is not running in a cluster, the Manager will return an error [quay_link]: https://quay.io [multi-namespaced-cache-builder]: https://godoc.org/github.com/kubernetes-sigs/controller-runtime/pkg/cache#MultiNamespacedCacheBuilder [scheme_builder]: https://godoc.org/sigs.k8s.io/controller-runtime/pkg/scheme#Builder +[typical-status-properties]: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties diff --git a/pkg/status/conditions.go b/pkg/status/conditions.go new file mode 100644 index 0000000000..6cd3b9da98 --- /dev/null +++ b/pkg/status/conditions.go @@ -0,0 +1,204 @@ +// Copyright 2020 The Operator-SDK 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 status + +import ( + "encoding/json" + "sort" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeclock "k8s.io/apimachinery/pkg/util/clock" +) + +// clock is used to set status condition timestamps. +// This variable makes it easier to test conditions. +var clock kubeclock.Clock = &kubeclock.RealClock{} + +// ConditionType is the type of the condition and is typically a CamelCased +// word or short phrase. +// +// Condition types should indicate state in the "abnormal-true" polarity. For +// example, if the condition indicates when a policy is invalid, the "is valid" +// case is probably the norm, so the condition should be called "Invalid". +type ConditionType string + +// ConditionReason is intended to be a one-word, CamelCase representation of +// the category of cause of the current status. It is intended to be used in +// concise output, such as one-line kubectl get output, and in summarizing +// occurrences of causes. +type ConditionReason string + +// Condition represents an observation of an object's state. Conditions are an +// extension mechanism intended to be used when the details of an observation +// are not a priori known or would not apply to all instances of a given Kind. +// +// Conditions should be added to explicitly convey properties that users and +// components care about rather than requiring those properties to be inferred +// from other observations. Once defined, the meaning of a Condition can not be +// changed arbitrarily - it becomes part of the API, and has the same +// backwards- and forwards-compatibility concerns of any other part of the API. +type Condition struct { + Type ConditionType `json:"type"` + Status corev1.ConditionStatus `json:"status"` + Reason ConditionReason `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` +} + +// IsTrue Condition whether the condition status is "True". +func (c Condition) IsTrue() bool { + return c.Status == corev1.ConditionTrue +} + +// IsFalse returns whether the condition status is "False". +func (c Condition) IsFalse() bool { + return c.Status == corev1.ConditionFalse +} + +// IsUnknown returns whether the condition status is "Unknown". +func (c Condition) IsUnknown() bool { + return c.Status == corev1.ConditionUnknown +} + +// DeepCopy returns a deep copy of the condition +func (c *Condition) DeepCopy() *Condition { + if c == nil { + return nil + } + out := *c + return &out +} + +// Conditions is a set of Condition instances. +// +// +kubebuilder:validation:Type=array +type Conditions map[ConditionType]Condition + +// NewConditions initializes a set of conditions with the given list of +// conditions. +func NewConditions(conds ...Condition) Conditions { + conditions := Conditions{} + for _, c := range conds { + conditions.SetCondition(c) + } + return conditions +} + +// IsTrueFor searches the set of conditions for a condition with the given +// ConditionType. If found, it returns `condition.IsTrue()`. If not found, +// it returns false. +func (conditions Conditions) IsTrueFor(t ConditionType) bool { + if condition, ok := conditions[t]; ok { + return condition.IsTrue() + } + return false +} + +// IsFalseFor searches the set of conditions for a condition with the given +// ConditionType. If found, it returns `condition.IsFalse()`. If not found, +// it returns false. +func (conditions Conditions) IsFalseFor(t ConditionType) bool { + if condition, ok := conditions[t]; ok { + return condition.IsFalse() + } + return false +} + +// IsUnknownFor searches the set of conditions for a condition with the given +// ConditionType. If found, it returns `condition.IsUnknown()`. If not found, +// it returns true. +func (conditions Conditions) IsUnknownFor(t ConditionType) bool { + if condition, ok := conditions[t]; ok { + return condition.IsUnknown() + } + return true +} + +// SetCondition adds (or updates) the set of conditions with the given +// condition. It returns a boolean value indicating whether the set condition +// is new or was a change to the existing condition with the same type. +func (conditions *Conditions) SetCondition(newCond Condition) bool { + if conditions == nil || *conditions == nil { + *conditions = make(map[ConditionType]Condition) + } + newCond.LastTransitionTime = metav1.Time{Time: clock.Now()} + + if condition, ok := (*conditions)[newCond.Type]; ok { + // If the condition status didn't change, use the existing + // condition's last transition time. + if condition.Status == newCond.Status { + newCond.LastTransitionTime = condition.LastTransitionTime + } + changed := condition.Status != newCond.Status || + condition.Reason != newCond.Reason || + condition.Message != newCond.Message + (*conditions)[newCond.Type] = newCond + return changed + } + (*conditions)[newCond.Type] = newCond + return true +} + +// GetCondition searches the set of conditions for the condition with the given +// ConditionType and returns it. If the matching condition is not found, +// GetCondition returns nil. +func (conditions Conditions) GetCondition(t ConditionType) *Condition { + if condition, ok := conditions[t]; ok { + return &condition + } + return nil +} + +// RemoveCondition removes the condition with the given ConditionType from +// the conditions set. If no condition with that type is found, RemoveCondition +// returns without performing any action. If the passed condition type is not +// found in the set of conditions, RemoveCondition returns false. +func (conditions *Conditions) RemoveCondition(t ConditionType) bool { + if conditions == nil || *conditions == nil { + return false + } + if _, ok := (*conditions)[t]; ok { + delete(*conditions, t) + return true + } + return false +} + +// MarshalJSON marshals the set of conditions as a JSON array, sorted by +// condition type. +func (conditions Conditions) MarshalJSON() ([]byte, error) { + conds := []Condition{} + for _, condition := range conditions { + conds = append(conds, condition) + } + sort.Slice(conds, func(a, b int) bool { + return conds[a].Type < conds[b].Type + }) + return json.Marshal(conds) +} + +// UnmarshalJSON unmarshals the JSON data into the set of Conditions. +func (conditions *Conditions) UnmarshalJSON(data []byte) error { + *conditions = make(map[ConditionType]Condition) + conds := []Condition{} + if err := json.Unmarshal(data, &conds); err != nil { + return err + } + for _, condition := range conds { + (*conditions)[condition.Type] = condition + } + return nil +} diff --git a/pkg/status/conditions_test.go b/pkg/status/conditions_test.go new file mode 100644 index 0000000000..d8a742ac0a --- /dev/null +++ b/pkg/status/conditions_test.go @@ -0,0 +1,263 @@ +// Copyright 2020 The Operator-SDK 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 status + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeclock "k8s.io/apimachinery/pkg/util/clock" +) + +var ( + initTime time.Time + clockInterval time.Duration +) + +func init() { + loc, _ := time.LoadLocation("Local") + initTime = time.Date(2015, time.July, 11, 0, 1, 0, 0, loc) + clockInterval = time.Hour +} + +func initConditions(init ...Condition) Conditions { + // Use the same initial time for all initial conditions + clock = kubeclock.NewFakeClock(initTime) + conditions := Conditions{} + for _, c := range init { + conditions.SetCondition(c) + } + + // Use an incrementing clock for the rest of the test + clock = &kubeclock.IntervalClock{ + Time: initTime, + Duration: clockInterval, + } + + return conditions +} + +func generateCondition(t ConditionType, s corev1.ConditionStatus) Condition { + c := Condition{ + Type: t, + Status: s, + Reason: ConditionReason(fmt.Sprintf("My%s%s", t, s)), + Message: fmt.Sprintf("Condition %s is %s", t, s), + } + return c +} + +func withLastTransitionTime(c Condition, t time.Time) Condition { + c.LastTransitionTime = metav1.Time{Time: t} + return c +} + +func TestConditionDeepCopy(t *testing.T) { + var n *Condition + assert.Nil(t, n.DeepCopy()) + + a := generateCondition("A", corev1.ConditionTrue) + aCopy := a.DeepCopy() + if &a == aCopy { + t.Errorf("Expected and actual point to the same object: %p %#v", &a, &a) + } + if &a.Status == &aCopy.Status { + t.Errorf("Expected and actual point to the same object: %p %#v", &a.Status, &a.Status) + } + if &a.Reason == &aCopy.Reason { + t.Errorf("Expected and actual point to the same object: %p %#v", &a.Reason, &a.Reason) + } + if &a.Message == &aCopy.Message { + t.Errorf("Expected and actual point to the same object: %p %#v", &a.Message, &a.Message) + } +} + +func TestConditionsSetEmpty(t *testing.T) { + conditions := initConditions() + + setCondition := generateCondition("A", corev1.ConditionTrue) + assert.True(t, conditions.SetCondition(setCondition)) + + expectedCondition := withLastTransitionTime(setCondition, initTime.Add(clockInterval)) + actualCondition := conditions.GetCondition(setCondition.Type) + assert.Equal(t, 1, len(conditions)) + assert.Equal(t, expectedCondition, *actualCondition) +} + +func TestConditionsSetNotExists(t *testing.T) { + conditions := initConditions(generateCondition("B", corev1.ConditionTrue)) + + setCondition := generateCondition("A", corev1.ConditionTrue) + assert.True(t, conditions.SetCondition(setCondition)) + + expectedCondition := withLastTransitionTime(setCondition, initTime.Add(clockInterval)) + actualCondition := conditions.GetCondition(expectedCondition.Type) + assert.Equal(t, 2, len(conditions)) + assert.Equal(t, expectedCondition, *actualCondition) +} + +func TestConditionsSetExistsIdentical(t *testing.T) { + existingCondition := generateCondition("A", corev1.ConditionTrue) + conditions := initConditions(existingCondition) + + setCondition := existingCondition + assert.False(t, conditions.SetCondition(setCondition)) + + expectedCondition := withLastTransitionTime(setCondition, initTime) + actualCondition := conditions.GetCondition(expectedCondition.Type) + assert.Equal(t, 1, len(conditions)) + assert.Equal(t, expectedCondition, *actualCondition) +} +func TestConditionsSetExistsDifferentReason(t *testing.T) { + existingCondition := generateCondition("A", corev1.ConditionTrue) + conditions := initConditions(existingCondition) + + setCondition := existingCondition + setCondition.Reason = "ChangedReason" + assert.True(t, conditions.SetCondition(setCondition)) + + expectedCondition := withLastTransitionTime(setCondition, initTime) + actualCondition := conditions.GetCondition(expectedCondition.Type) + assert.Equal(t, 1, len(conditions)) + assert.Equal(t, expectedCondition, *actualCondition) +} + +func TestConditionsSetExistsDifferentStatus(t *testing.T) { + existingCondition := generateCondition("A", corev1.ConditionTrue) + conditions := initConditions(existingCondition) + + setCondition := existingCondition + setCondition.Status = corev1.ConditionFalse + setCondition.Reason = "ChangedReason" + assert.True(t, conditions.SetCondition(setCondition)) + + expectedCondition := withLastTransitionTime(setCondition, initTime.Add(clockInterval)) + actualCondition := conditions.GetCondition(expectedCondition.Type) + assert.Equal(t, 1, len(conditions)) + assert.Equal(t, expectedCondition, *actualCondition) +} + +func TestConditionsGetNotExists(t *testing.T) { + conditions := initConditions(generateCondition("A", corev1.ConditionTrue)) + + actualCondition := conditions.GetCondition(ConditionType("B")) + assert.Nil(t, actualCondition) +} + +func TestConditionsRemoveNotExists(t *testing.T) { + conditions := initConditions( + generateCondition("A", corev1.ConditionTrue), + generateCondition("B", corev1.ConditionTrue), + ) + + assert.False(t, conditions.RemoveCondition(ConditionType("C"))) + a := conditions.GetCondition(ConditionType("A")) + b := conditions.GetCondition(ConditionType("B")) + assert.NotNil(t, a) + assert.NotNil(t, b) + assert.Equal(t, 2, len(conditions)) +} + +func TestConditionsRemoveExists(t *testing.T) { + conditions := initConditions( + generateCondition("A", corev1.ConditionTrue), + generateCondition("B", corev1.ConditionTrue), + ) + + assert.True(t, conditions.RemoveCondition(ConditionType("A"))) + a := conditions.GetCondition(ConditionType("A")) + b := conditions.GetCondition(ConditionType("B")) + assert.Nil(t, a) + assert.NotNil(t, b) + assert.Equal(t, 1, len(conditions)) +} + +func TestConditionsIsTrueFor(t *testing.T) { + conditions := NewConditions( + generateCondition("False", corev1.ConditionFalse), + generateCondition("True", corev1.ConditionTrue), + generateCondition("Unknown", corev1.ConditionUnknown), + ) + + assert.True(t, conditions.IsTrueFor(ConditionType("True"))) + assert.False(t, conditions.IsTrueFor(ConditionType("False"))) + assert.False(t, conditions.IsTrueFor(ConditionType("Unknown"))) + assert.False(t, conditions.IsTrueFor(ConditionType("DoesNotExist"))) +} + +func TestConditionsIsFalseFor(t *testing.T) { + conditions := NewConditions( + generateCondition("False", corev1.ConditionFalse), + generateCondition("True", corev1.ConditionTrue), + generateCondition("Unknown", corev1.ConditionUnknown), + ) + + assert.False(t, conditions.IsFalseFor(ConditionType("True"))) + assert.True(t, conditions.IsFalseFor(ConditionType("False"))) + assert.False(t, conditions.IsFalseFor(ConditionType("Unknown"))) + assert.False(t, conditions.IsFalseFor(ConditionType("DoesNotExist"))) +} + +func TestConditionsIsUnknownFor(t *testing.T) { + conditions := NewConditions( + generateCondition("False", corev1.ConditionFalse), + generateCondition("True", corev1.ConditionTrue), + generateCondition("Unknown", corev1.ConditionUnknown), + ) + + assert.False(t, conditions.IsUnknownFor(ConditionType("True"))) + assert.False(t, conditions.IsUnknownFor(ConditionType("False"))) + assert.True(t, conditions.IsUnknownFor(ConditionType("Unknown"))) + assert.True(t, conditions.IsUnknownFor(ConditionType("DoesNotExist"))) +} + +func TestConditionsMarshalUnmarshalJSON(t *testing.T) { + a := generateCondition("A", corev1.ConditionTrue) + b := generateCondition("B", corev1.ConditionTrue) + c := generateCondition("C", corev1.ConditionTrue) + d := generateCondition("D", corev1.ConditionTrue) + + // Insert conditions unsorted + conditions := initConditions(b, d, c, a) + + data, err := json.Marshal(conditions) + if err != nil { + t.Fatalf("Failed to marshal JSON: %s", err) + } + + // Test that conditions are in sorted order by type. + in := []Condition{} + err = json.Unmarshal(data, &in) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %s", err) + } + assert.Equal(t, a.Type, in[0].Type) + assert.Equal(t, b.Type, in[1].Type) + assert.Equal(t, c.Type, in[2].Type) + assert.Equal(t, d.Type, in[3].Type) + + // Test that the marshal/unmarshal cycle is lossless. + unmarshalConds := Conditions{} + err = json.Unmarshal(data, &unmarshalConds) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %s", err) + } + assert.Equal(t, conditions, unmarshalConds) +}