Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func (r *DeploymentResource) Mutate(current client.Object) error {

// 1. Apply core desired state to the current object.
// This ensures that the base fields are always correct before features apply their changes.
r.applyCoreDesiredState(current)
r.applyCoreDesiredState(currentDeployment)

// 2. Apply feature mutations via a restricted mutator interface
// We've applied all desired fields from the core object and can now continue working
Expand Down Expand Up @@ -810,7 +810,7 @@ func (r *DeploymentResource) Mutate(current client.Object) error {

// 1. Apply core desired state to the current object.
// This ensures that the base fields are always correct before features apply their changes.
r.applyCoreDesiredState(current)
r.applyCoreDesiredState(currentDeployment)

// 2. Apply feature mutations via a restricted mutator interface
// We've applied all desired fields from the core object and can now continue working
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type DeploymentBuilder struct {
func NewDeploymentBuilder(deployment *appsv1.Deployment) *DeploymentBuilder {
return &DeploymentBuilder{
res: &DeploymentResource{
desired: deployment,
deployment: deployment,
},
}
}
Expand Down Expand Up @@ -105,14 +105,14 @@ func (b *DeploymentBuilder) WithDataExtractor(
// Build finalizes and returns the configured DeploymentResource.
// It returns an error if the desired is nil, or if it lacks a name or namespace.
func (b *DeploymentBuilder) Build() (*DeploymentResource, error) {
if b.res.desired == nil {
return nil, errors.New("desired cannot be nil")
if b.res.deployment == nil {
return nil, errors.New("deployment cannot be nil")
}
if b.res.desired.Name == "" {
return nil, errors.New("desired name cannot be empty")
if b.res.deployment.Name == "" {
return nil, errors.New("deployment name cannot be empty")
}
if b.res.desired.Namespace == "" {
return nil, errors.New("desired namespace cannot be empty")
if b.res.deployment.Namespace == "" {
return nil, errors.New("deployment namespace cannot be empty")
}
return b.res, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resources

import (
"fmt"
"maps"

"github.com/sourcehawk/operator-component-framework/pkg/feature"
appsv1 "k8s.io/api/apps/v1"
Expand All @@ -18,8 +19,9 @@ import (
// This abstraction exists to decouple the reconciliation logic from the specific
// Kubernetes type, allowing the framework to handle lifecycle and status aggregation.
type DeploymentResource struct {
// desired is the underlying Kubernetes object.
desired *appsv1.Deployment
// deployment becomes the underlying Kubernetes object after a reconcile and is
// the desired core object before a reconcile (more concretely before a successful return from Mutate).
deployment *appsv1.Deployment
// mutations is a list of feature-gated changes to apply to the resource.
mutations []feature.Mutation[*DeploymentResourceMutator]

Expand All @@ -43,12 +45,13 @@ type DeploymentResource struct {

// Identity returns a unique identifier for the resource, used by the framework for logging and tracking.
func (r *DeploymentResource) Identity() string {
return fmt.Sprintf("apps/v1/Deployment/%s", r.desired.Name)
return fmt.Sprintf("apps/v1/Deployment/%s", r.deployment.Name)
}

// DesiredDefaultObject returns the underlying Kubernetes object. It implements the Resource interface.
func (r *DeploymentResource) DesiredDefaultObject() (client.Object, error) {
return r.desired.DeepCopy(), nil
// Object returns a copy of the underlying Kubernetes object after reconcile or the unchanged desired object
// before a reconcile happens. It implements the Resource interface.
func (r *DeploymentResource) Object() (client.Object, error) {
return r.deployment.DeepCopy(), nil
}

// Mutate applies the desired state to the resource, including Feature Mutations.
Expand Down Expand Up @@ -90,7 +93,7 @@ func (r *DeploymentResource) Mutate(current client.Object) error {
// 4. Update internal desired state with the mutated current object.
// This ensures that subsequent calls to ConvergingStatus and ExtractData
// use the fully mutated state, including status.
r.desired = currentDeployment.DeepCopy()
r.deployment = currentDeployment.DeepCopy()

return nil
}
Expand All @@ -106,43 +109,39 @@ func (r *DeploymentResource) applyCoreDesiredState(current *appsv1.Deployment) {
if current.Labels == nil {
current.Labels = make(map[string]string)
}
for k, v := range r.desired.Labels {
current.Labels[k] = v
}
maps.Copy(current.Labels, r.deployment.Labels)

if current.Annotations == nil {
current.Annotations = make(map[string]string)
}
for k, v := range r.desired.Annotations {
current.Annotations[k] = v
}
maps.Copy(current.Annotations, r.deployment.Annotations)

// Apply Spec fields
current.Spec.Replicas = r.desired.Spec.Replicas
current.Spec.Selector = r.desired.Spec.Selector
current.Spec.Template.Labels = r.desired.Spec.Template.Labels
current.Spec.Template.Annotations = r.desired.Spec.Template.Annotations
current.Spec.Replicas = r.deployment.Spec.Replicas
current.Spec.Selector = r.deployment.Spec.Selector
current.Spec.Template.Labels = r.deployment.Spec.Template.Labels
current.Spec.Template.Annotations = r.deployment.Spec.Template.Annotations

// For containers, we might want a more sophisticated merge, but for this example
// we just ensure the core containers exist with their base settings.
// This is where the "core" part of the resource definition is enforced.
current.Spec.Template.Spec.Containers = make([]corev1.Container, len(r.desired.Spec.Template.Spec.Containers))
copy(current.Spec.Template.Spec.Containers, r.desired.Spec.Template.Spec.Containers)
current.Spec.Template.Spec.Containers = make([]corev1.Container, len(r.deployment.Spec.Template.Spec.Containers))
copy(current.Spec.Template.Spec.Containers, r.deployment.Spec.Template.Spec.Containers)
}

// ConvergingStatus reports the progress of the resource toward its desired state.
// It implements the Alive interface, allowing the Component to aggregate status.
func (r *DeploymentResource) ConvergingStatus(op component.ConvergingOperation) (component.ConvergingStatusWithReason, error) {
if r.convergeStatusHandler != nil {
return r.convergeStatusHandler(op, r.desired)
return r.convergeStatusHandler(op, r.deployment)
}

desiredReplicas := int32(1)
if r.desired.Spec.Replicas != nil {
desiredReplicas = *r.desired.Spec.Replicas
if r.deployment.Spec.Replicas != nil {
desiredReplicas = *r.deployment.Spec.Replicas
}

if r.desired.Status.ReadyReplicas == desiredReplicas {
if r.deployment.Status.ReadyReplicas == desiredReplicas {
return component.ConvergingStatusWithReason{
Status: component.ConvergingStatusReady,
Reason: "All replicas are ready",
Expand All @@ -163,7 +162,7 @@ func (r *DeploymentResource) ConvergingStatus(op component.ConvergingOperation)
Status: status,
Reason: fmt.Sprintf(
"Waiting for replicas: %d/%d ready",
r.desired.Status.ReadyReplicas,
r.deployment.Status.ReadyReplicas,
desiredReplicas,
),
}, nil
Expand All @@ -173,10 +172,10 @@ func (r *DeploymentResource) ConvergingStatus(op component.ConvergingOperation)
// It's part of the Alive interface used for sophisticated status reporting.
func (r *DeploymentResource) GraceStatus() (component.GraceStatusWithReason, error) {
if r.graceStatusHandler != nil {
return r.graceStatusHandler(r.desired)
return r.graceStatusHandler(r.deployment)
}

if r.desired.Status.ReadyReplicas > 0 {
if r.deployment.Status.ReadyReplicas > 0 {
return component.GraceStatusWithReason{
Status: component.GraceStatusDegraded,
Reason: "Deployment partially available",
Expand All @@ -192,13 +191,13 @@ func (r *DeploymentResource) GraceStatus() (component.GraceStatusWithReason, err
// IsSuspended checks if the resource is currently in a suspended state (e.g., scaled to 0).
// It implements the Suspendable interface.
func (r *DeploymentResource) IsSuspended() bool {
return r.desired.Spec.Replicas != nil && *r.desired.Spec.Replicas == 0
return r.deployment.Spec.Replicas != nil && *r.deployment.Spec.Replicas == 0
}

// DeleteOnSuspend determines if the resource should be deleted when the component is suspended.
func (r *DeploymentResource) DeleteOnSuspend() bool {
if r.suspendDeletionDecisionHandler != nil {
return r.suspendDeletionDecisionHandler(r.desired)
return r.suspendDeletionDecisionHandler(r.deployment)
}
return false
}
Expand Down Expand Up @@ -227,10 +226,10 @@ func (r *DeploymentResource) Suspend() error {
// SuspensionStatus reports the progress of the resource toward a suspended state.
func (r *DeploymentResource) SuspensionStatus() (component.SuspensionStatusWithReason, error) {
if r.suspendStatusHandler != nil {
return r.suspendStatusHandler(r.desired)
return r.suspendStatusHandler(r.deployment)
}

if r.desired.Status.Replicas == 0 {
if r.deployment.Status.Replicas == 0 {
return component.SuspensionStatusWithReason{
Status: component.SuspensionStatusSuspended,
Reason: "Deployment scaled to zero",
Expand All @@ -241,7 +240,7 @@ func (r *DeploymentResource) SuspensionStatus() (component.SuspensionStatusWithR
Status: component.SuspensionStatusSuspending,
Reason: fmt.Sprintf(
"Waiting for replicas to scale down, %d replicas still running.",
r.desired.Status.Replicas,
r.deployment.Status.Replicas,
),
}, nil
}
Expand All @@ -250,7 +249,7 @@ func (r *DeploymentResource) SuspensionStatus() (component.SuspensionStatusWithR
// It implements the DataExtractable interface.
func (r *DeploymentResource) ExtractData() error {
// We enure no data mutations are applied by extractors
deploymentCopy := r.desired.DeepCopy()
deploymentCopy := r.deployment.DeepCopy()

for _, extractor := range r.dataExtractors {
if extractor == nil {
Expand Down
40 changes: 20 additions & 20 deletions pkg/component/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ var _ = Describe("Component Reconciler", func() {
Data: map[string]string{"foo": "bar"},
}
res := &MockResource{}
res.On("DesiredDefaultObject").Return(cm, nil)
res.On("Object").Return(cm, nil)
res.On("Identity").Return("ConfigMap/test-cm")
res.On("Mutate", mock.Anything).Return(nil)

Expand Down Expand Up @@ -130,7 +130,7 @@ var _ = Describe("Component Reconciler", func() {
},
}
res := &MockAliveResource{}
res.On("DesiredDefaultObject").Return(cm, nil)
res.On("Object").Return(cm, nil)
res.On("Identity").Return("ConfigMap/test-alive-cm")
res.On("Mutate", mock.Anything).Return(nil)
res.On("ConvergingStatus", ConvergingOperationCreated).Return(ConvergingStatusWithReason{
Expand Down Expand Up @@ -164,7 +164,7 @@ var _ = Describe("Component Reconciler", func() {
Expect(k8sClient.Create(ctx, cm)).To(Succeed())

res := &MockAliveResource{}
res.On("DesiredDefaultObject").Return(cm, nil)
res.On("Object").Return(cm, nil)
res.On("Identity").Return("ConfigMap/test-readonly-cm")
res.On("ConvergingStatus", ConvergingOperationNone).Return(ConvergingStatusWithReason{
Status: ConvergingStatusReady,
Expand All @@ -191,7 +191,7 @@ var _ = Describe("Component Reconciler", func() {
ObjectMeta: metav1.ObjectMeta{Name: "create-cm", Namespace: namespace},
}
res1 := &MockAliveResource{}
res1.On("DesiredDefaultObject").Return(cm1, nil)
res1.On("Object").Return(cm1, nil)
res1.On("Identity").Return("ConfigMap/create-cm")
res1.On("Mutate", mock.Anything).Return(nil)
res1.On("ConvergingStatus", ConvergingOperationCreated).Return(ConvergingStatusWithReason{
Expand All @@ -205,7 +205,7 @@ var _ = Describe("Component Reconciler", func() {
Expect(k8sClient.Create(ctx, cm2)).To(Succeed())

res2 := &MockAliveResource{}
res2.On("DesiredDefaultObject").Return(cm2, nil)
res2.On("Object").Return(cm2, nil)
res2.On("Identity").Return("ConfigMap/read-cm")
res2.On("ConvergingStatus", ConvergingOperationNone).Return(ConvergingStatusWithReason{
Status: ConvergingStatusCreating, // Dominant status (False/Creating)
Expand Down Expand Up @@ -259,13 +259,13 @@ var _ = Describe("Component Reconciler", func() {
Data: map[string]string{"foo": "bar"},
}
createRes := &MockResource{}
createRes.On("DesiredDefaultObject").Return(cm, nil)
createRes.On("Object").Return(cm, nil)
createRes.On("Identity").Return("ConfigMap/should-not-be-created")
createRes.On("Mutate", mock.Anything).Return(nil)

// Set up suspendable resource
suspendRes := &MockSuspendableResource{}
suspendRes.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
suspendRes.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "suspend-me", Namespace: namespace},
}, nil)
suspendRes.On("Identity").Return("suspend-me")
Expand Down Expand Up @@ -309,7 +309,7 @@ var _ = Describe("Component Reconciler", func() {
Expect(k8sClient.Create(ctx, cm)).To(Succeed())

res := &MockSuspendableResource{}
res.On("DesiredDefaultObject").Return(cm, nil)
res.On("Object").Return(cm, nil)
res.On("Identity").Return("ConfigMap/test-suspended-cm")
res.On("Suspend").Return(nil)
res.On("Mutate", mock.Anything).Return(nil)
Expand Down Expand Up @@ -352,7 +352,7 @@ var _ = Describe("Component Reconciler", func() {
Expect(k8sClient.Create(ctx, cm)).To(Succeed())

res := &MockResource{}
res.On("DesiredDefaultObject").Return(cm, nil)
res.On("Object").Return(cm, nil)
res.On("Identity").Return("ConfigMap/to-be-deleted")

comp.deleteResources = []Resource{res}
Expand All @@ -375,7 +375,7 @@ var _ = Describe("Component Reconciler", func() {
It("should update status to Error when reconciliation fails", func() {
// Given
res := &MockResource{}
res.On("DesiredDefaultObject").Return(nil, fmt.Errorf("reconciliation error"))
res.On("Object").Return(nil, fmt.Errorf("reconciliation error"))
res.On("Identity").Return("failing-resource")

comp.createResources = []Resource{res}
Expand All @@ -397,7 +397,7 @@ var _ = Describe("Component Reconciler", func() {
It("should handle errors during read-only resource retrieval", func() {
// Given
res := &MockResource{}
res.On("DesiredDefaultObject").Return(nil, fmt.Errorf("read error"))
res.On("Object").Return(nil, fmt.Errorf("read error"))
res.On("Identity").Return("failing-read-resource")

comp.readResources = []Resource{res}
Expand All @@ -418,7 +418,7 @@ var _ = Describe("Component Reconciler", func() {
It("should handle errors during resource deletion in normal flow", func() {
// Given
res := &MockResource{}
res.On("DesiredDefaultObject").Return(nil, fmt.Errorf("delete object error"))
res.On("Object").Return(nil, fmt.Errorf("delete object error"))
res.On("Identity").Return("failing-delete-resource")

comp.deleteResources = []Resource{res}
Expand Down Expand Up @@ -463,7 +463,7 @@ var _ = Describe("Component Reconciler", func() {
comp.suspended = true
// A resource that suspends successfully
susRes := &MockSuspendableResource{}
susRes.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
susRes.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "suspend-ok", Namespace: namespace},
}, nil)
susRes.On("Identity").Return("suspend-ok")
Expand All @@ -474,7 +474,7 @@ var _ = Describe("Component Reconciler", func() {

// A resource that fails deletion
delRes := &MockResource{}
delRes.On("DesiredDefaultObject").Return(nil, fmt.Errorf("suspend-delete error"))
delRes.On("Object").Return(nil, fmt.Errorf("suspend-delete error"))
delRes.On("Identity").Return("failing-suspend-delete-resource")

comp.createResources = []Resource{susRes}
Expand All @@ -498,7 +498,7 @@ var _ = Describe("Component Reconciler", func() {
It("should execute extraction during normal reconcile flow", func() {
// Given
res := &MockExtractableResource{}
res.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: namespace},
}, nil)
res.On("Identity").Return("ConfigMap/test-cm")
Expand All @@ -525,7 +525,7 @@ var _ = Describe("Component Reconciler", func() {
// Let's create a combined mock or just use MockExtractableResource and see if it's called.
// Reconcile checks c.suspended FIRST.

res.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: namespace},
}, nil)
res.On("Identity").Return("ConfigMap/test-cm")
Expand All @@ -549,7 +549,7 @@ var _ = Describe("Component Reconciler", func() {
It("should propagate extraction errors and set status to Error", func() {
// Given
res := &MockExtractableResource{}
res.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: namespace},
}, nil)
res.On("Identity").Return("ConfigMap/test-cm")
Expand All @@ -573,15 +573,15 @@ var _ = Describe("Component Reconciler", func() {
It("should handle multiple resources with and without DataExtractable", func() {
// Given
res1 := &MockExtractableResource{}
res1.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res1.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "res1", Namespace: namespace},
}, nil)
res1.On("Identity").Return("ConfigMap/res1")
res1.On("Mutate", mock.Anything).Return(nil)
res1.On("ExtractData").Return(nil)

res2 := &MockResource{}
res2.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res2.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "res2", Namespace: namespace},
}, nil)
res2.On("Identity").Return("ConfigMap/res2")
Expand Down Expand Up @@ -609,7 +609,7 @@ var _ = Describe("Component Reconciler", func() {
return nil
},
}
res.On("DesiredDefaultObject").Return(&corev1.ConfigMap{
res.On("Object").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ext-cm", Namespace: namespace},
}, nil)
res.On("Identity").Return("ConfigMap/ext-cm")
Expand Down
Loading
Loading