diff --git a/api/v1alpha1/harborconnection_types.go b/api/v1alpha1/harborconnection_types.go index d99bbef..4421dd7 100644 --- a/api/v1alpha1/harborconnection_types.go +++ b/api/v1alpha1/harborconnection_types.go @@ -31,8 +31,11 @@ type Credentials struct { // HarborConnectionStatus defines the observed state of HarborConnection. type HarborConnectionStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + // Conditions represent the latest available observations of the HarborConnection's state. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/member_types.go b/api/v1alpha1/member_types.go index daf3219..12ee2fb 100644 --- a/api/v1alpha1/member_types.go +++ b/api/v1alpha1/member_types.go @@ -48,7 +48,11 @@ type MemberSpec struct { // MemberStatus defines the observed state of Member. type MemberStatus struct { - // Optionally add status fields, e.g. to track creation state or Harbor member ID. + // Conditions represent the latest available observations of the Member's state. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/project_types.go b/api/v1alpha1/project_types.go index d29558e..e19dcd0 100644 --- a/api/v1alpha1/project_types.go +++ b/api/v1alpha1/project_types.go @@ -73,6 +73,12 @@ type CVEAllowlist struct { type ProjectStatus struct { // HarborProjectID is the ID of the project in Harbor. HarborProjectID int `json:"harborProjectID,omitempty"` + + // Conditions represent the latest available observations of the Project's state. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/registry_types.go b/api/v1alpha1/registry_types.go index 18e7c27..f32adeb 100644 --- a/api/v1alpha1/registry_types.go +++ b/api/v1alpha1/registry_types.go @@ -34,6 +34,12 @@ type RegistrySpec struct { type RegistryStatus struct { // HarborRegistryID is the ID of the registry in Harbor. HarborRegistryID int `json:"harborRegistryID,omitempty"` + + // Conditions represent the latest available observations of the Registry's state. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/user_types.go b/api/v1alpha1/user_types.go index db152fa..ae8e011 100644 --- a/api/v1alpha1/user_types.go +++ b/api/v1alpha1/user_types.go @@ -35,6 +35,12 @@ type UserSpec struct { type UserStatus struct { // HarborUserID is the ID of the user in Harbor. HarborUserID int `json:"harborUserID,omitempty"` + + // Conditions represent the latest available observations of the User's state. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 805e610..98fb181 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -84,7 +84,7 @@ func (in *HarborConnection) DeepCopyInto(out *HarborConnection) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarborConnection. @@ -160,6 +160,13 @@ func (in *HarborConnectionSpec) DeepCopy() *HarborConnectionSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HarborConnectionStatus) DeepCopyInto(out *HarborConnectionStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarborConnectionStatus. @@ -198,7 +205,7 @@ func (in *Member) DeepCopyInto(out *Member) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Member. @@ -295,6 +302,13 @@ func (in *MemberSpec) DeepCopy() *MemberSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MemberStatus) DeepCopyInto(out *MemberStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberStatus. @@ -328,7 +342,7 @@ func (in *Project) DeepCopyInto(out *Project) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Project. @@ -425,6 +439,13 @@ func (in *ProjectSpec) DeepCopy() *ProjectSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProjectStatus) DeepCopyInto(out *ProjectStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectStatus. @@ -443,7 +464,7 @@ func (in *Registry) DeepCopyInto(out *Registry) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Registry. @@ -515,6 +536,13 @@ func (in *RegistrySpec) DeepCopy() *RegistrySpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RegistryStatus) DeepCopyInto(out *RegistryStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryStatus. @@ -548,7 +576,7 @@ func (in *User) DeepCopyInto(out *User) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new User. @@ -621,6 +649,13 @@ func (in *UserSpec) DeepCopy() *UserSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserStatus) DeepCopyInto(out *UserStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStatus. diff --git a/config/crd/bases/harbor.harbor-operator.io_harborconnections.yaml b/config/crd/bases/harbor.harbor-operator.io_harborconnections.yaml index a6deca7..9a5d9dd 100644 --- a/config/crd/bases/harbor.harbor-operator.io_harborconnections.yaml +++ b/config/crd/bases/harbor.harbor-operator.io_harborconnections.yaml @@ -93,6 +93,68 @@ spec: type: object status: description: HarborConnectionStatus defines the observed state of HarborConnection. + properties: + conditions: + description: Conditions represent the latest available observations + of the HarborConnection's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map type: object type: object served: true diff --git a/config/crd/bases/harbor.harbor-operator.io_members.yaml b/config/crd/bases/harbor.harbor-operator.io_members.yaml index 4933521..c35e2fb 100644 --- a/config/crd/bases/harbor.harbor-operator.io_members.yaml +++ b/config/crd/bases/harbor.harbor-operator.io_members.yaml @@ -93,6 +93,68 @@ spec: type: object status: description: MemberStatus defines the observed state of Member. + properties: + conditions: + description: Conditions represent the latest available observations + of the Member's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map type: object type: object served: true diff --git a/config/crd/bases/harbor.harbor-operator.io_projects.yaml b/config/crd/bases/harbor.harbor-operator.io_projects.yaml index b453fee..0c35adf 100644 --- a/config/crd/bases/harbor.harbor-operator.io_projects.yaml +++ b/config/crd/bases/harbor.harbor-operator.io_projects.yaml @@ -136,6 +136,67 @@ spec: status: description: ProjectStatus defines the observed state of Project. properties: + conditions: + description: Conditions represent the latest available observations + of the Project's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map harborProjectID: description: HarborProjectID is the ID of the project in Harbor. type: integer diff --git a/config/crd/bases/harbor.harbor-operator.io_registries.yaml b/config/crd/bases/harbor.harbor-operator.io_registries.yaml index 09b552c..393604f 100644 --- a/config/crd/bases/harbor.harbor-operator.io_registries.yaml +++ b/config/crd/bases/harbor.harbor-operator.io_registries.yaml @@ -97,6 +97,67 @@ spec: status: description: RegistryStatus defines the observed state of Registry. properties: + conditions: + description: Conditions represent the latest available observations + of the Registry's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map harborRegistryID: description: HarborRegistryID is the ID of the registry in Harbor. type: integer diff --git a/config/crd/bases/harbor.harbor-operator.io_users.yaml b/config/crd/bases/harbor.harbor-operator.io_users.yaml index a81f597..28be140 100644 --- a/config/crd/bases/harbor.harbor-operator.io_users.yaml +++ b/config/crd/bases/harbor.harbor-operator.io_users.yaml @@ -103,6 +103,67 @@ spec: status: description: UserStatus defines the observed state of User. properties: + conditions: + description: Conditions represent the latest available observations + of the User's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map harborUserID: description: HarborUserID is the ID of the user in Harbor. type: integer diff --git a/internal/controller/conditions.go b/internal/controller/conditions.go new file mode 100644 index 0000000..302d10a --- /dev/null +++ b/internal/controller/conditions.go @@ -0,0 +1,121 @@ +package controller + +import ( + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Condition types following kstatus conventions. +// See: https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus +const ( + // TypeReady indicates the resource is ready and reconciled. + TypeReady = "Ready" + + // TypeReconciling indicates the controller is actively working on the resource. + TypeReconciling = "Reconciling" + + // TypeStalled indicates the resource is stuck in a failed state. + TypeStalled = "Stalled" +) + +// Condition reasons. +const ( + // ReasonReconcileSuccess indicates successful reconciliation. + ReasonReconcileSuccess = "ReconcileSuccess" + + // ReasonReconcileError indicates a reconciliation error. + ReasonReconcileError = "ReconcileError" + + // ReasonCreating indicates resource is being created. + ReasonCreating = "Creating" + + // ReasonUpdating indicates resource is being updated. + ReasonUpdating = "Updating" + + // ReasonAdopting indicates resource is being adopted. + ReasonAdopting = "Adopting" + + // ReasonConnectionFailed indicates failed connection to Harbor. + ReasonConnectionFailed = "ConnectionFailed" + + // ReasonInvalidSpec indicates the spec is invalid. + ReasonInvalidSpec = "InvalidSpec" +) + +// SetCondition adds or updates a condition in the conditions slice. +// If a condition with the same type already exists, it will be updated only if the status has changed. +func SetCondition(conditions *[]metav1.Condition, conditionType string, status metav1.ConditionStatus, reason, message string) { + now := metav1.NewTime(time.Now()) + newCondition := metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: now, + } + + for i, condition := range *conditions { + if condition.Type == conditionType { + // Only update if the status has changed + if condition.Status != status { + newCondition.LastTransitionTime = now + } else { + newCondition.LastTransitionTime = condition.LastTransitionTime + } + (*conditions)[i] = newCondition + return + } + } + + // Condition doesn't exist, append it + *conditions = append(*conditions, newCondition) +} + +// GetCondition returns a condition by type from the conditions slice. +func GetCondition(conditions []metav1.Condition, conditionType string) *metav1.Condition { + for _, condition := range conditions { + if condition.Type == conditionType { + return &condition + } + } + return nil +} + +// RemoveCondition removes a condition by type from the conditions slice. +func RemoveCondition(conditions *[]metav1.Condition, conditionType string) { + newConditions := []metav1.Condition{} + for _, condition := range *conditions { + if condition.Type != conditionType { + newConditions = append(newConditions, condition) + } + } + *conditions = newConditions +} + +// SetReadyCondition is a convenience function to set the Ready condition. +func SetReadyCondition(conditions *[]metav1.Condition, ready bool, reason, message string) { + status := metav1.ConditionTrue + if !ready { + status = metav1.ConditionFalse + } + SetCondition(conditions, TypeReady, status, reason, message) +} + +// SetReconcilingCondition is a convenience function to set the Reconciling condition. +func SetReconcilingCondition(conditions *[]metav1.Condition, reconciling bool, reason, message string) { + status := metav1.ConditionTrue + if !reconciling { + status = metav1.ConditionFalse + } + SetCondition(conditions, TypeReconciling, status, reason, message) +} + +// SetStalledCondition is a convenience function to set the Stalled condition. +func SetStalledCondition(conditions *[]metav1.Condition, stalled bool, reason, message string) { + status := metav1.ConditionTrue + if !stalled { + status = metav1.ConditionFalse + } + SetCondition(conditions, TypeStalled, status, reason, message) +} diff --git a/internal/controller/conditions_test.go b/internal/controller/conditions_test.go new file mode 100644 index 0000000..b7fde29 --- /dev/null +++ b/internal/controller/conditions_test.go @@ -0,0 +1,183 @@ +package controller + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSetCondition(t *testing.T) { + conditions := []metav1.Condition{} + + // Test adding a new condition + SetCondition(&conditions, TypeReady, metav1.ConditionTrue, ReasonReconcileSuccess, "Test message") + + if len(conditions) != 1 { + t.Errorf("Expected 1 condition, got %d", len(conditions)) + } + + if conditions[0].Type != TypeReady { + t.Errorf("Expected condition type %s, got %s", TypeReady, conditions[0].Type) + } + + if conditions[0].Status != metav1.ConditionTrue { + t.Errorf("Expected status True, got %s", conditions[0].Status) + } + + if conditions[0].Reason != ReasonReconcileSuccess { + t.Errorf("Expected reason %s, got %s", ReasonReconcileSuccess, conditions[0].Reason) + } + + // Test updating an existing condition with same status (should not change timestamp) + firstTimestamp := conditions[0].LastTransitionTime + time.Sleep(10 * time.Millisecond) // Ensure time passes + SetCondition(&conditions, TypeReady, metav1.ConditionTrue, ReasonReconcileSuccess, "Updated message") + + if len(conditions) != 1 { + t.Errorf("Expected 1 condition after update, got %d", len(conditions)) + } + + if !conditions[0].LastTransitionTime.Equal(&firstTimestamp) { + t.Errorf("Expected timestamp to remain the same when status doesn't change") + } + + // Test updating with different status (should change timestamp) + time.Sleep(10 * time.Millisecond) + SetCondition(&conditions, TypeReady, metav1.ConditionFalse, ReasonReconcileError, "Error message") + + if len(conditions) != 1 { + t.Errorf("Expected 1 condition after status change, got %d", len(conditions)) + } + + if conditions[0].Status != metav1.ConditionFalse { + t.Errorf("Expected status False, got %s", conditions[0].Status) + } + + if conditions[0].LastTransitionTime.Equal(&firstTimestamp) { + t.Errorf("Expected timestamp to change when status changes") + } + + // Test adding a second condition + SetCondition(&conditions, TypeReconciling, metav1.ConditionTrue, ReasonCreating, "Creating resource") + + if len(conditions) != 2 { + t.Errorf("Expected 2 conditions, got %d", len(conditions)) + } +} + +func TestGetCondition(t *testing.T) { + conditions := []metav1.Condition{ + { + Type: TypeReady, + Status: metav1.ConditionTrue, + Reason: ReasonReconcileSuccess, + Message: "Test", + LastTransitionTime: metav1.Now(), + }, + } + + // Test getting existing condition + cond := GetCondition(conditions, TypeReady) + if cond == nil { + t.Fatal("Expected to find condition, got nil") + } + if cond.Type != TypeReady { + t.Errorf("Expected condition type %s, got %s", TypeReady, cond.Type) + } + + // Test getting non-existent condition + cond = GetCondition(conditions, TypeStalled) + if cond != nil { + t.Error("Expected nil for non-existent condition") + } +} + +func TestRemoveCondition(t *testing.T) { + conditions := []metav1.Condition{ + { + Type: TypeReady, + Status: metav1.ConditionTrue, + Reason: ReasonReconcileSuccess, + LastTransitionTime: metav1.Now(), + }, + { + Type: TypeReconciling, + Status: metav1.ConditionTrue, + Reason: ReasonCreating, + LastTransitionTime: metav1.Now(), + }, + } + + // Test removing existing condition + RemoveCondition(&conditions, TypeReady) + if len(conditions) != 1 { + t.Errorf("Expected 1 condition after removal, got %d", len(conditions)) + } + if conditions[0].Type != TypeReconciling { + t.Errorf("Expected remaining condition to be %s, got %s", TypeReconciling, conditions[0].Type) + } + + // Test removing non-existent condition (should be no-op) + RemoveCondition(&conditions, TypeStalled) + if len(conditions) != 1 { + t.Errorf("Expected 1 condition after no-op removal, got %d", len(conditions)) + } +} + +func TestSetReadyCondition(t *testing.T) { + conditions := []metav1.Condition{} + + // Test setting ready to true + SetReadyCondition(&conditions, true, ReasonReconcileSuccess, "Ready") + if len(conditions) != 1 { + t.Fatalf("Expected 1 condition, got %d", len(conditions)) + } + if conditions[0].Status != metav1.ConditionTrue { + t.Errorf("Expected True status, got %s", conditions[0].Status) + } + + // Test setting ready to false + SetReadyCondition(&conditions, false, ReasonReconcileError, "Not ready") + if conditions[0].Status != metav1.ConditionFalse { + t.Errorf("Expected False status, got %s", conditions[0].Status) + } +} + +func TestSetReconcilingCondition(t *testing.T) { + conditions := []metav1.Condition{} + + // Test setting reconciling to true + SetReconcilingCondition(&conditions, true, ReasonCreating, "Creating") + if len(conditions) != 1 { + t.Fatalf("Expected 1 condition, got %d", len(conditions)) + } + if conditions[0].Status != metav1.ConditionTrue { + t.Errorf("Expected True status, got %s", conditions[0].Status) + } + if conditions[0].Type != TypeReconciling { + t.Errorf("Expected type %s, got %s", TypeReconciling, conditions[0].Type) + } +} + +func TestSetStalledCondition(t *testing.T) { + conditions := []metav1.Condition{} + + // Test setting stalled to true + SetStalledCondition(&conditions, true, ReasonConnectionFailed, "Connection failed") + if len(conditions) != 1 { + t.Fatalf("Expected 1 condition, got %d", len(conditions)) + } + if conditions[0].Status != metav1.ConditionTrue { + t.Errorf("Expected True status, got %s", conditions[0].Status) + } + if conditions[0].Type != TypeStalled { + t.Errorf("Expected type %s, got %s", TypeStalled, conditions[0].Type) + } + + // Test setting stalled to false + SetStalledCondition(&conditions, false, ReasonReconcileSuccess, "") + if conditions[0].Status != metav1.ConditionFalse { + t.Errorf("Expected False status, got %s", conditions[0].Status) + } +} diff --git a/internal/controller/harborconnection_controller.go b/internal/controller/harborconnection_controller.go index 96a96d0..93bef07 100644 --- a/internal/controller/harborconnection_controller.go +++ b/internal/controller/harborconnection_controller.go @@ -48,9 +48,16 @@ func (r *HarborConnectionReconciler) Reconcile(ctx context.Context, req ctrl.Req // Validate the BaseURL. if err := r.validateBaseURL(&conn); err != nil { + SetReadyCondition(&conn.Status.Conditions, false, ReasonInvalidSpec, err.Error()) + SetStalledCondition(&conn.Status.Conditions, true, ReasonInvalidSpec, err.Error()) + _ = r.Status().Update(ctx, &conn) return ctrl.Result{}, err } + // Set reconciling condition + SetReconcilingCondition(&conn.Status.Conditions, true, ReasonReconcileSuccess, "Checking Harbor connectivity") + _ = r.Status().Update(ctx, &conn) + // If no credentials are provided, perform a non-authenticated connectivity check. if conn.Spec.Credentials == nil { return r.checkNonAuthConnectivity(ctx, &conn) @@ -80,9 +87,17 @@ func (r *HarborConnectionReconciler) checkNonAuthConnectivity( hc := harborclient.New(conn.Spec.BaseURL, "", "") // no creds if err := hc.Ping(ctx); err != nil { + SetReadyCondition(&conn.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to connect to Harbor: %v", err)) + SetStalledCondition(&conn.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + SetReconcilingCondition(&conn.Status.Conditions, false, ReasonReconcileError, "Connection failed") + _ = r.Status().Update(ctx, conn) return ctrl.Result{}, err } r.logger.Info("Harbor reachable without credentials") + SetReadyCondition(&conn.Status.Conditions, true, ReasonReconcileSuccess, "Harbor is reachable") + SetReconcilingCondition(&conn.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&conn.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, conn) return ctrl.Result{}, nil } @@ -92,15 +107,27 @@ func (r *HarborConnectionReconciler) checkAuthenticatedConnection( user := conn.Spec.Credentials.Username pass, err := r.getPassword(ctx, r.Client, conn) // unchanged helper if err != nil { + SetReadyCondition(&conn.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get credentials: %v", err)) + SetStalledCondition(&conn.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + SetReconcilingCondition(&conn.Status.Conditions, false, ReasonReconcileError, "Failed to get credentials") + _ = r.Status().Update(ctx, conn) return ctrl.Result{}, err } hc := harborclient.New(conn.Spec.BaseURL, user, pass) if _, err := hc.GetCurrentUser(ctx); err != nil { + SetReadyCondition(&conn.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to authenticate with Harbor: %v", err)) + SetStalledCondition(&conn.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + SetReconcilingCondition(&conn.Status.Conditions, false, ReasonReconcileError, "Authentication failed") + _ = r.Status().Update(ctx, conn) return ctrl.Result{}, err } r.logger.Info("Successfully authenticated with Harbor API") + SetReadyCondition(&conn.Status.Conditions, true, ReasonReconcileSuccess, "Successfully authenticated with Harbor") + SetReconcilingCondition(&conn.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&conn.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, conn) return ctrl.Result{}, nil } diff --git a/internal/controller/member_controller.go b/internal/controller/member_controller.go index ba7473b..435a3d2 100644 --- a/internal/controller/member_controller.go +++ b/internal/controller/member_controller.go @@ -48,18 +48,27 @@ func (r *MemberReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr conn, err := getHarborConnection(ctx, r.Client, member.Namespace, member.Spec.HarborConnectionRef) if err != nil { r.logger.Error(err, "Failed to get HarborConnection", "HarborConnectionRef", member.Spec.HarborConnectionRef) + SetReadyCondition(&member.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get HarborConnection: %v", err)) + SetStalledCondition(&member.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &member) return ctrl.Result{}, err } if conn.Spec.Credentials == nil { err := fmt.Errorf("HarborConnection %s/%s has no credentials configured", conn.Namespace, conn.Name) r.logger.Error(err, "Cannot manage Harbor members without credentials") + SetReadyCondition(&member.Status.Conditions, false, ReasonInvalidSpec, err.Error()) + SetStalledCondition(&member.Status.Conditions, true, ReasonInvalidSpec, err.Error()) + _ = r.Status().Update(ctx, &member) return ctrl.Result{}, err } user, pass, err := getHarborAuth(ctx, r.Client, conn) if err != nil { r.logger.Error(err, "Failed to get Harbor authentication credentials") + SetReadyCondition(&member.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get Harbor credentials: %v", err)) + SetStalledCondition(&member.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &member) return ctrl.Result{}, err } @@ -91,17 +100,30 @@ func (r *MemberReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr roleID, err := convertRoleNameToID(member.Spec.Role) if err != nil { r.logger.Error(err, "Invalid role", "Role", member.Spec.Role) + SetReadyCondition(&member.Status.Conditions, false, ReasonInvalidSpec, fmt.Sprintf("Invalid role: %v", err)) + SetStalledCondition(&member.Status.Conditions, true, ReasonInvalidSpec, err.Error()) + _ = r.Status().Update(ctx, &member) return ctrl.Result{}, err } // Ensure desired member state in Harbor (create/update as needed). + SetReconcilingCondition(&member.Status.Conditions, true, ReasonReconcileSuccess, "Ensuring member in Harbor") + _ = r.Status().Update(ctx, &member) if err := r.ensureMemberPresent(ctx, hc, &member, roleID); err != nil { r.logger.Error(err, "Failed to ensure member in Harbor", "ProjectRef", member.Spec.ProjectRef, "RoleID", roleID) + SetReadyCondition(&member.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to ensure member: %v", err)) + SetStalledCondition(&member.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&member.Status.Conditions, false, ReasonReconcileError, "Member reconciliation failed") + _ = r.Status().Update(ctx, &member) return ctrl.Result{}, err } + SetReadyCondition(&member.Status.Conditions, true, ReasonReconcileSuccess, "Member reconciled successfully") + SetReconcilingCondition(&member.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&member.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, &member) return returnWithDriftDetection(&member.Spec.HarborSpecBase) } diff --git a/internal/controller/project_controller.go b/internal/controller/project_controller.go index 622583b..8e6b842 100644 --- a/internal/controller/project_controller.go +++ b/internal/controller/project_controller.go @@ -45,10 +45,16 @@ func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Resolve Harbor connection + typed client conn, err := getHarborConnection(ctx, r.Client, cr.Namespace, cr.Spec.HarborConnectionRef) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get HarborConnection: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } user, pass, err := getHarborAuth(ctx, r.Client, conn) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get Harbor credentials: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } hc := harborclient.New(conn.Spec.BaseURL, user, pass) @@ -81,7 +87,13 @@ func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } if cr.Status.HarborProjectID == 0 && cr.Spec.AllowTakeover { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonAdopting, "Attempting to adopt existing project") + _ = r.Status().Update(ctx, &cr) if adopted, err := r.adoptExisting(ctx, hc, &cr); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to adopt project: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Adoption failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } else if adopted { r.logger.Info("Adopted existing project", @@ -92,17 +104,29 @@ func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Desired payload createReq, err := r.buildCreateReq(ctx, hc, &cr) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonInvalidSpec, fmt.Sprintf("Failed to build project request: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonInvalidSpec, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } // Create / Update path if cr.Status.HarborProjectID == 0 { // create + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonCreating, "Creating project in Harbor") + _ = r.Status().Update(ctx, &cr) newID, err := hc.CreateProject(ctx, createReq) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to create project: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Creation failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } cr.Status.HarborProjectID = newID + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "Project created successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") if err := r.Status().Update(ctx, &cr); err != nil { return ctrl.Result{}, err } @@ -116,20 +140,35 @@ func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if harborclient.IsNotFound(err) { // It was deleted out-of-band → clear status and requeue immediately cr.Status.HarborProjectID = 0 + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Project was deleted out-of-band") + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonReconcileError, "Recreating project") _ = r.Status().Update(ctx, &cr) return ctrl.Result{Requeue: true}, nil } + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to get project: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } // compare desired vs. current if projectNeedsUpdate(createReq, *current) { // update + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonUpdating, "Updating project in Harbor") + _ = r.Status().Update(ctx, &cr) if err := hc.UpdateProject(ctx, current.ProjectID, createReq); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to update project: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Update failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } r.logger.Info("Updated project", "ID", current.ProjectID) } + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "Project reconciled successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, &cr) return returnWithDriftDetection(&cr.Spec.HarborSpecBase) } diff --git a/internal/controller/registry_controller.go b/internal/controller/registry_controller.go index 25fa841..dce00e9 100644 --- a/internal/controller/registry_controller.go +++ b/internal/controller/registry_controller.go @@ -43,10 +43,16 @@ func (r *RegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // Harbor client conn, err := getHarborConnection(ctx, r.Client, cr.Namespace, cr.Spec.HarborConnectionRef) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get HarborConnection: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } user, pass, err := getHarborAuth(ctx, r.Client, conn) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get Harbor credentials: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } hc := harborclient.New(conn.Spec.BaseURL, user, pass) @@ -75,7 +81,13 @@ func (r *RegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } if cr.Status.HarborRegistryID == 0 && cr.Spec.AllowTakeover { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonAdopting, "Attempting to adopt existing registry") + _ = r.Status().Update(ctx, &cr) if ok, err := r.adoptExisting(ctx, hc, &cr); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to adopt registry: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Adoption failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } else if ok { r.logger.Info("Adopted registry", "ID", cr.Status.HarborRegistryID) @@ -87,11 +99,20 @@ func (r *RegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // Create / Update if cr.Status.HarborRegistryID == 0 { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonCreating, "Creating registry in Harbor") + _ = r.Status().Update(ctx, &cr) id, err := hc.CreateRegistry(ctx, createReq) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to create registry: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Creation failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } cr.Status.HarborRegistryID = id + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "Registry created successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") _ = r.Status().Update(ctx, &cr) r.logger.Info("Created registry", "ID", id) return returnWithDriftDetection(&cr.Spec.HarborSpecBase) @@ -101,18 +122,33 @@ func (r *RegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c if err != nil { if harborclient.IsNotFound(err) { cr.Status.HarborRegistryID = 0 + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Registry was deleted out-of-band") + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonReconcileError, "Recreating registry") _ = r.Status().Update(ctx, &cr) return ctrl.Result{Requeue: true}, nil } + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to get registry: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } if registryNeedsUpdate(createReq, *current) { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonUpdating, "Updating registry in Harbor") + _ = r.Status().Update(ctx, &cr) if err := hc.UpdateRegistry(ctx, current.ID, createReq); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to update registry: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Update failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } r.logger.Info("Updated registry", "ID", current.ID) } + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "Registry reconciled successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, &cr) return returnWithDriftDetection(&cr.Spec.HarborSpecBase) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 1e9ae00..3bc2013 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -45,10 +45,16 @@ func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. // Harbor client conn, err := getHarborConnection(ctx, r.Client, cr.Namespace, cr.Spec.HarborConnectionRef) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get HarborConnection: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } user, pass, err := getHarborAuth(ctx, r.Client, conn) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonConnectionFailed, fmt.Sprintf("Failed to get Harbor credentials: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonConnectionFailed, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } hc := harborclient.New(conn.Spec.BaseURL, user, pass) @@ -77,7 +83,13 @@ func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } if cr.Status.HarborUserID == 0 && cr.Spec.AllowTakeover { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonAdopting, "Attempting to adopt existing user") + _ = r.Status().Update(ctx, &cr) if ok, err := r.adoptExisting(ctx, hc, &cr); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to adopt user: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Adoption failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } else if ok { r.logger.Info("Adopted user", "ID", cr.Status.HarborUserID) @@ -87,17 +99,29 @@ func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. // Desired payload userPassword, err := r.getUserPassword(ctx, r.Client, cr) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonInvalidSpec, fmt.Sprintf("Failed to get user password: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonInvalidSpec, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } createReq := r.buildCreateReq(cr, userPassword) // Create / Update if cr.Status.HarborUserID == 0 { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonCreating, "Creating user in Harbor") + _ = r.Status().Update(ctx, &cr) id, err := hc.CreateUser(ctx, createReq) if err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to create user: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Creation failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } cr.Status.HarborUserID = id + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "User created successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") _ = r.Status().Update(ctx, &cr) return returnWithDriftDetection(&cr.Spec.HarborSpecBase) } @@ -106,17 +130,32 @@ func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. if err != nil { if harborclient.IsNotFound(err) { cr.Status.HarborUserID = 0 + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, "User was deleted out-of-band") + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonReconcileError, "Recreating user") _ = r.Status().Update(ctx, &cr) return ctrl.Result{Requeue: true}, nil } + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to get user: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } if userNeedsUpdate(createReq, current) { + SetReconcilingCondition(&cr.Status.Conditions, true, ReasonUpdating, "Updating user in Harbor") + _ = r.Status().Update(ctx, &cr) if err := hc.UpdateUser(ctx, current.UserID, createReq); err != nil { + SetReadyCondition(&cr.Status.Conditions, false, ReasonReconcileError, fmt.Sprintf("Failed to update user: %v", err)) + SetStalledCondition(&cr.Status.Conditions, true, ReasonReconcileError, err.Error()) + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileError, "Update failed") + _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } } + SetReadyCondition(&cr.Status.Conditions, true, ReasonReconcileSuccess, "User reconciled successfully") + SetReconcilingCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "Reconciliation complete") + SetStalledCondition(&cr.Status.Conditions, false, ReasonReconcileSuccess, "") + _ = r.Status().Update(ctx, &cr) return returnWithDriftDetection(&cr.Spec.HarborSpecBase) }