Skip to content
19 changes: 16 additions & 3 deletions apis/common/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ const (

// Reasons a resource is or is not synced.
const (
ReasonReconcileSuccess ConditionReason = "ReconcileSuccess"
ReasonReconcileError ConditionReason = "ReconcileError"
ReasonReconcilePaused ConditionReason = "ReconcilePaused"
ReasonReconcileSuccess ConditionReason = "ReconcileSuccess"
ReasonReconcileError ConditionReason = "ReconcileError"
ReasonReconcilePaused ConditionReason = "ReconcilePaused"
ReasonReconcileForbidden ConditionReason = "ReconcileForbidden"
)

// See https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
Expand Down Expand Up @@ -312,3 +313,15 @@ func ReconcilePaused() Condition {
Reason: ReasonReconcilePaused,
}
}

// ReconcileForbidden returns a condition that indicates reconciliation on
// the managed resource is forbidden because managementPolicy 'Update' is missing
// and a diff between spec and the external resource exists.
func ReconcileForbidden() Condition {
return Condition{
Type: TypeSynced,
Status: corev1.ConditionFalse,
LastTransitionTime: metav1.Now(),
Reason: ReasonReconcileForbidden,
}
}
7 changes: 7 additions & 0 deletions apis/common/v1/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,10 @@ func ReconcileError(err error) Condition {
func ReconcilePaused() Condition {
return common.ReconcilePaused()
}

// ReconcileForbidden returns a condition that indicates reconciliation on
// the managed resource is forbidden because managementPolicy 'Update' is missing
// and a diff between spec and the external resource exists.
func ReconcileForbidden() Condition {
return common.ReconcileForbidden()
}
13 changes: 12 additions & 1 deletion pkg/reconciler/managed/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package managed

import (
"context"
"fmt"
"math/rand"
"strings"
"time"
Expand Down Expand Up @@ -56,6 +57,7 @@ const (
const (
errFmtManagementPolicyNonDefault = "`spec.managementPolicies` is set to a non-default value but the feature is not enabled: %s"
errFmtManagementPolicyNotSupported = "`spec.managementPolicies` is set to a value(%s) which is not supported. Check docs for supported policies"
errExternalResourceDiffNoUpdate = "External resource differs from desired state, but will not update due to missing 'Update' managementPolicy."

errGetManaged = "cannot get managed resource"
errUpdateManagedAnnotations = "cannot update managed resource annotations"
Expand Down Expand Up @@ -1470,7 +1472,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (resu
if !policy.ShouldUpdate() {
reconcileAfter := r.pollIntervalHook(managed, r.pollInterval)
log.Debug("Skipping update due to managementPolicies. Reconciliation succeeded", "requeue-after", time.Now().Add(reconcileAfter))
status.MarkConditions(xpv1.ReconcileSuccess())
// since there is a diff between spec and upstream, we want to inform the user
// about the diff, but that we will not update it - indicating that we WOULD perform
// changes if we could. This is helpful in migration scenarios to crossplane where
// the diff between actual and desired must be analyzed for potential impacts first,
// before giving Crossplane control over the resource.
msg := errExternalResourceDiffNoUpdate
if observation.Diff != "" {
msg = fmt.Sprintf("%s Diff:\n%s", errExternalResourceDiffNoUpdate, observation.Diff)
}
status.MarkConditions(xpv1.ReconcileForbidden().WithMessage(msg))

return reconcile.Result{RequeueAfter: reconcileAfter}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
}
Expand Down
120 changes: 112 additions & 8 deletions pkg/reconciler/managed/reconciler_legacy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1765,7 +1765,7 @@ func TestReconciler(t *testing.T) {
want: want{result: reconcile.Result{Requeue: true}},
},
"ObserveOnlySuccessfulObserve": {
reason: "With Observe, a successful managed resource observe should trigger a requeue after a long wait.",
reason: "With Observe, a successfully managed resource observe should trigger a requeue after a long wait.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
Expand All @@ -1778,7 +1778,59 @@ func TestReconciler(t *testing.T) {
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newLegacyManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve})
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42))
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "With ObserveOnly, a successfully managed resource observation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

return nil
}),
},
Scheme: fake.SchemeWith(&fake.LegacyManaged{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.LegacyManaged{})),
o: []ReconcilerOption{
WithInitializers(),
WithManagementPolicies(),
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, nil
},
DisconnectFn: func(_ context.Context) error {
return nil
},
}

return c, nil
})),
withConnectionPublishers(ConnectionPublisherFns{
PublishConnectionFn: func(_ context.Context, _ resource.ConnectionSecretOwner, _ ConnectionDetails) (bool, error) {
return false, nil
},
}),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{RequeueAfter: defaultPollInterval}},
},
"ObserveOnlyWithDiffSyncError": {
reason: "With Observe, if the external resource is not up to date with desired, it should show a Synced=False + ReconcileForbidden error condition, and should trigger a requeue after a long wait.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
mg := asLegacyManaged(obj, 42)
mg.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve})

return nil
}),
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newLegacyManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve})
want.SetConditions(xpv1.ReconcileForbidden().WithObservedGeneration(42).WithMessage("External resource differs from desired state, but will not update due to missing 'Update' managementPolicy. Diff:\nmock diff"))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "With ObserveOnly, a successful managed resource observation should be reported as a conditioned status."
Expand All @@ -1797,7 +1849,7 @@ func TestReconciler(t *testing.T) {
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true}, nil
return ExternalObservation{ResourceExists: true, ResourceUpToDate: false, Diff: "mock diff"}, nil
},
DisconnectFn: func(_ context.Context) error {
return nil
Expand Down Expand Up @@ -2026,7 +2078,7 @@ func TestReconciler(t *testing.T) {
},
want: want{result: reconcile.Result{Requeue: false}},
},
"ManagementPolicyImmutable": {
"ManagementPolicyImmutableNoDiff": {
reason: "Successful reconciliation skipping update should trigger a requeue after a long wait.",
args: args{
m: &fake.Manager{
Expand All @@ -2044,7 +2096,7 @@ func TestReconciler(t *testing.T) {
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := `Managed resource should acquire Synced=False/ReconcileSuccess status condition.`
reason := `Managed resource should acquire Synced=True/ReconcileSuccess status condition.`
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

Expand All @@ -2061,7 +2113,59 @@ func TestReconciler(t *testing.T) {
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true, ResourceUpToDate: false}, nil
return ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, nil
},
UpdateFn: func(_ context.Context, _ resource.Managed) (ExternalUpdate, error) {
return ExternalUpdate{}, errBoom
},
DisconnectFn: func(_ context.Context) error {
return nil
},
}

return c, nil
})),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{RequeueAfter: defaultPollInterval}},
},
"ManagementPolicyImmutableWithDiff": {
reason: "Reconciliation without being allowed to update should trigger a Sync Paused condition and a requeue after a long wait.",
args: args{
Comment on lines +2133 to +2135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reason text says “Sync Paused,” but expected condition is ReconcileForbidden.

Could we update the case reason to match the actual condition being asserted? It’ll make failures far easier to interpret.

Suggested edit
- reason: "Reconciliation without being allowed to update should trigger a Sync Paused condition and a requeue after a long wait.",
+ reason: "Reconciliation without update permission should trigger ReconcileForbidden and a requeue after a long wait.",

As per coding guidelines, “Check for proper test case naming and reason fields.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"ManagementPolicyImmutableWithDiff": {
reason: "Reconciliation without being allowed to update should trigger a Sync Paused condition and a requeue after a long wait.",
args: args{
"ManagementPolicyImmutableWithDiff": {
reason: "Reconciliation without update permission should trigger ReconcileForbidden and a requeue after a long wait.",
args: args{
🤖 Prompt for AI Agents
In `@pkg/reconciler/managed/reconciler_legacy_test.go` around lines 2133 - 2135,
The test case "ManagementPolicyImmutableWithDiff" has a reason string that says
"Reconciliation without being allowed to update should trigger a Sync Paused
condition..." but the test actually asserts for the ReconcileForbidden
condition; update the reason field for the ManagementPolicyImmutableWithDiff
case to reference ReconcileForbidden (e.g., "Reconciliation without being
allowed to update should trigger a ReconcileForbidden condition and a requeue
after a long wait") so the case name, reason and assertions are consistent.

m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
mg := asLegacyManaged(obj, 42)
mg.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionLateInitialize, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete})

return nil
}),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newLegacyManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionLateInitialize, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete})
want.SetConditions(xpv1.ReconcileForbidden().WithObservedGeneration(42).WithMessage("External resource differs from desired state, but will not update due to missing 'Update' managementPolicy. Diff:\nmock diff"))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := `Managed resource should acquire Synced=False/ReconcileForbidden status condition when update is not allowed and a diff exists.`
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

return nil
}),
},
Scheme: fake.SchemeWith(&fake.LegacyManaged{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.LegacyManaged{})),
o: []ReconcilerOption{
WithInitializers(),
WithManagementPolicies(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true, ResourceUpToDate: false, Diff: "mock diff"}, nil
},
UpdateFn: func(_ context.Context, _ resource.Managed) (ExternalUpdate, error) {
return ExternalUpdate{}, errBoom
Expand Down Expand Up @@ -2092,7 +2196,7 @@ func TestReconciler(t *testing.T) {
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newLegacyManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionAll})
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42))
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "A successful managed resource update should be reported as a conditioned status."
Expand Down Expand Up @@ -2142,7 +2246,7 @@ func TestReconciler(t *testing.T) {
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newLegacyManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionOrphan})
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42))
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42))
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "A successful managed resource update should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
Expand Down
66 changes: 59 additions & 7 deletions pkg/reconciler/managed/reconciler_modern_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1770,7 +1770,7 @@ func TestModernReconciler(t *testing.T) {
},
want: want{result: reconcile.Result{Requeue: true}},
},
"ObserveOnlySuccessfulObserve": {
"ObserveOnlySuccessfulObserveNoDiff": {
reason: "With Observe, a successful managed resource observe should trigger a requeue after a long wait.",
args: args{
m: &fake.Manager{
Expand All @@ -1787,7 +1787,7 @@ func TestModernReconciler(t *testing.T) {
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "With ObserveOnly, a successful managed resource observation should be reported as a conditioned status."
reason := "With ObserveOnly, a successful managed resource observation without any diff between external resource and desired spec should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

Expand All @@ -1803,7 +1803,7 @@ func TestModernReconciler(t *testing.T) {
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true}, nil
return ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, nil
},
DisconnectFn: func(_ context.Context) error {
return nil
Expand Down Expand Up @@ -2032,8 +2032,8 @@ func TestModernReconciler(t *testing.T) {
},
want: want{result: reconcile.Result{Requeue: false}},
},
"ManagementPolicyImmutable": {
reason: "Successful reconciliation skipping update should trigger a requeue after a long wait.",
"ManagementPolicyImmutableNoDiff": {
reason: "Successful reconciliation without upstream diff and skipping update should trigger a requeue after a long wait",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
Expand All @@ -2050,7 +2050,7 @@ func TestModernReconciler(t *testing.T) {
want.SetConditions(xpv1.ReconcileSuccess().WithObservedGeneration(42))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := `Managed resource should acquire Synced=False/ReconcileSuccess status condition.`
reason := `Managed resource should acquire Synced=True/ReconcileSuccess status condition.`
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

Expand All @@ -2067,7 +2067,59 @@ func TestModernReconciler(t *testing.T) {
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true, ResourceUpToDate: false}, nil
return ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, nil
},
UpdateFn: func(_ context.Context, _ resource.Managed) (ExternalUpdate, error) {
return ExternalUpdate{}, errBoom
},
DisconnectFn: func(_ context.Context) error {
return nil
},
}

return c, nil
})),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{RequeueAfter: defaultPollInterval}},
},
"ManagementPolicyImmutableWithDiff": {
reason: "Successful reconciliation with upstream diff and skipping update should trigger a requeue after a long wait.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
mg := asModernManaged(obj, 42)
mg.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionLateInitialize, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete})

return nil
}),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockStatusUpdate: test.MockSubResourceUpdateFn(func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
want := newModernManaged(42)
want.SetManagementPolicies(xpv1.ManagementPolicies{xpv1.ManagementActionObserve, xpv1.ManagementActionLateInitialize, xpv1.ManagementActionCreate, xpv1.ManagementActionDelete})
want.SetConditions(xpv1.ReconcileForbidden().WithObservedGeneration(42).WithMessage("External resource differs from desired state, but will not update due to missing 'Update' managementPolicy. Diff:\nmock diff"))

if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := `Managed resource should acquire Synced=False/ReconcileForbidden status condition.`
Comment on lines +2088 to +2105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reword the case reason to match ReconcileForbidden.

This test expects ReconcileForbidden, but the case reason says “Successful reconciliation,” which is misleading. Could you tighten the wording?

Suggested edit
- reason: "Successful reconciliation with upstream diff and skipping update should trigger a requeue after a long wait.",
+ reason: "Reconciliation with upstream diff and no update permission should set ReconcileForbidden and requeue.",

As per coding guidelines, “Check for proper test case naming and reason fields.”

🤖 Prompt for AI Agents
In `@pkg/reconciler/managed/reconciler_modern_test.go` around lines 2088 - 2105,
Update the test case's reason string to accurately describe the expected
ReconcileForbidden outcome: replace the misleading "Successful
reconciliation..." message with a concise description such as
"ReconcileForbidden when external resource differs but Update managementPolicy
is missing (skips update and requeues long)" so it matches the asserted
condition ReconcileForbidden in the test that sets the ManagementPolicies and
expects the Synced=False/ReconcileForbidden condition in the MockStatusUpdate
for asModernManaged/newModernManaged.

t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}

return nil
}),
},
Scheme: fake.SchemeWith(&fake.ModernManaged{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.ModernManaged{})),
o: []ReconcilerOption{
WithInitializers(),
WithManagementPolicies(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnector(ExternalConnectorFn(func(_ context.Context, _ resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: true, ResourceUpToDate: false, Diff: "mock diff"}, nil
},
UpdateFn: func(_ context.Context, _ resource.Managed) (ExternalUpdate, error) {
return ExternalUpdate{}, errBoom
Expand Down