diff --git a/apis/conditionset.go b/apis/conditionset.go index aa9d3ba..68ac01c 100644 --- a/apis/conditionset.go +++ b/apis/conditionset.go @@ -87,6 +87,12 @@ type ConditionManager interface { InitializeConditions() } +// ConditionManagerSetter allows the condition manager to be accessed from a resource. +// It is implemented by the default Status subresource implementation. +type ConditionManagerSetter interface { + SetConditionManager(ConditionManager) +} + // NewLivingConditionSet returns a ConditionSet to hold the conditions for the // living resource. ConditionReady is used as the happy condition. // The set of condition types provided are those of the terminal subconditions. @@ -164,11 +170,17 @@ func (r ConditionSet) Manage(status ConditionsAccessor) ConditionManager { // Manage creates a ConditionManager from an accessor object using the original // ConditionSet as a reference. Status must be a pointer to a struct. func (r ConditionSet) ManageWithContext(ctx context.Context, status ConditionsAccessor) ConditionManager { - return conditionsImpl{ + cm := conditionsImpl{ accessor: status, ConditionSet: r, now: rtime.RetrieveNow(ctx), } + + if s, isConditionManagerSetter := status.(ConditionManagerSetter); isConditionManagerSetter { + s.SetConditionManager(cm) + } + + return cm } // IsHappy looks at the happy condition and returns true if that condition is diff --git a/apis/status_types.go b/apis/status_types.go index 4e8bf0b..84022a0 100644 --- a/apis/status_types.go +++ b/apis/status_types.go @@ -41,10 +41,30 @@ type Status struct { // +patchMergeKey=type // +patchStrategy=merge Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + + conditionManagerWrapper `json:"-"` // for an explanation why this exists, see below +} + +// conditionManagerWrapper exists so as to allow the Status struct to implement the ConditionManager interface. +// The ConditionManager interface cannot be embedded directly because the `deepcopy-gen` generator then fails. +// Embedding an unexported wrapper struct and marking the field as excluded via the `json:"-"` struct tag allows +// the code generation to succeed. +// This wouldn't be necessary if fields could be excluded from `deepcopy-gen`. +// +k8s:deepcopy-gen=false +type conditionManagerWrapper struct { + ConditionManager } +// DeepCopyInto is defined as a no-op. It's necessary because `deepcopy-gen` isn't running for unexported structs +// but will still generate a call to it in the parent struct. +func (w *conditionManagerWrapper) DeepCopyInto(_ *conditionManagerWrapper) {} + var _ ConditionsAccessor = (*Status)(nil) +var _ ConditionManager = (*Status)(nil) + +var _ ConditionManagerSetter = (*Status)(nil) + // GetConditions implements ConditionsAccessor func (s *Status) GetConditions() []metav1.Condition { return s.Conditions @@ -64,3 +84,8 @@ func (s *Status) GetCondition(t string) *metav1.Condition { } return nil } + +// SetConditionManager satisfies the ConditionManagerSetter interface. +func (s *Status) SetConditionManager(cm ConditionManager) { + s.conditionManagerWrapper = conditionManagerWrapper{ConditionManager: cm} +} diff --git a/apis/status_types_test.go b/apis/status_types_test.go new file mode 100644 index 0000000..bd6dfcd --- /dev/null +++ b/apis/status_types_test.go @@ -0,0 +1,105 @@ +package apis + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + rtime "reconciler.io/runtime/time" +) + +const ( + ConditionCreated string = "Created" + ConditionConfigured string = "Configured" +) + +var conditionSet = NewLivingConditionSet(ConditionCreated, ConditionConfigured) + +type TestStatus struct { + Status `json:",inline"` +} + +func (s *TestStatus) InitializeConditions(ctx context.Context) { + conditionSet.ManageWithContext(ctx, s).InitializeConditions() +} + +func TestStatusConditionManager(t *testing.T) { + now := metav1.Date(2025, time.March, 1, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + ctx context.Context + run func(*testing.T, *TestStatus) + wantErr error + }{ + { + name: "unhappy and ready condition unknown when just initialized", + ctx: context.Background(), + run: func(t *testing.T, s *TestStatus) { + if actual := s.IsHappy(); actual == true { + t.Errorf("%s: IsHappy() actually = %v, expected %v", t.Name(), actual, false) + } + + readyCondition := s.GetCondition(ConditionReady) + if actual := ConditionIsUnknown(readyCondition); actual != true { + t.Errorf("%s: ConditionIsUnknown() actually = %v, expected %v", t.Name(), actual, true) + } + }, + }, + { + name: "setting Created condition to false", + ctx: context.Background(), + run: func(t *testing.T, s *TestStatus) { + s.MarkFalse(ConditionCreated, ConditionCreated, "") + + readyCondition := s.GetCondition(ConditionReady) + if actual := ConditionIsFalse(readyCondition); actual != true { + t.Errorf("%s: ConditionIsFalse() actually = %v, expected %v", t.Name(), actual, true) + } + + conditionCreated := s.GetCondition(ConditionCreated) + if actual := ConditionIsFalse(conditionCreated); actual != true { + t.Errorf("%s: ConditionIsFalse() actually = %v, expected %v", t.Name(), actual, true) + } + + conditionConfigured := s.GetCondition(ConditionConfigured) + if actual := ConditionIsUnknown(conditionConfigured); actual != true { + t.Errorf("%s: ConditionIsUnknown() actually = %v, expected %v", t.Name(), actual, true) + } + }, + }, + { + name: "setting all conditions sets the ready condition to true and happy", + ctx: rtime.StashNow(context.Background(), now.Time), + run: func(t *testing.T, s *TestStatus) { + s.MarkTrue(ConditionCreated, ConditionCreated, "") + s.MarkTrue(ConditionConfigured, ConditionConfigured, "") + + readyCondition := s.GetCondition(ConditionReady) + if actual := ConditionIsTrue(readyCondition); actual != true { + t.Errorf("%s: ConditionIsTrue() actually = %v, expected %v", t.Name(), actual, true) + } + + // failure to update this test will break it when adding a new terminal condition + if actual := s.IsHappy(); actual != true { + t.Errorf("%s: IsHappy() actually = %v, expected %v", t.Name(), actual, true) + } + + // check time as well + for _, condition := range s.GetConditions() { + if equal := condition.LastTransitionTime.Equal(&now); !equal { + t.Errorf("%s: LastTransitionTime.Equal() actually = %v, expected %v", t.Name(), equal, true) + } + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &TestStatus{} + s.InitializeConditions(tt.ctx) + tt.run(t, s) + }) + } +} diff --git a/apis/zz_generated.deepcopy.go b/apis/zz_generated.deepcopy.go index ef14e7b..2c788ea 100644 --- a/apis/zz_generated.deepcopy.go +++ b/apis/zz_generated.deepcopy.go @@ -34,6 +34,7 @@ func (in *Status) DeepCopyInto(out *Status) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + in.conditionManagerWrapper.DeepCopyInto(&out.conditionManagerWrapper) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Status.