Skip to content

Implement UpToDate condition in managed reconciler#945

Draft
bobh66 wants to merge 4 commits intocrossplane:mainfrom
nokia:uptodate2
Draft

Implement UpToDate condition in managed reconciler#945
bobh66 wants to merge 4 commits intocrossplane:mainfrom
nokia:uptodate2

Conversation

@bobh66
Copy link
Copy Markdown
Contributor

@bobh66 bobh66 commented Apr 10, 2026

Description of your changes

Adds a new UpToDate condition (name is subject to better suggestions) that exposes the ResourceUpToDate status from the Observe() call. This allows users to know whether the ManagedResource desired state matches the external resource, and whether an update has been requested, failed, or restricted by management policies.

There are five basic (non-error condition) scenarios:

Reconciliation is paused so the condition status is Unknown
The resource is up to date with the remote resource so the condition is True
The resource has differences from the remote resource and an Update() call has failed, the condition is False
The resource has differences from the remote resource and an Update() call cannot be made due to restrictions from managementPolicies, the condition is False
The resource has differences from the remote resource and Update() returned success - the condition is False because we can't know if the update is actually asynchronous and may yet fail, but if it succeeds the next reconciliation will show the changes from the update and the condition should become True.

The three False condition scenarios will include the contents of the observation.Diff if it is available.

This is important when importing existing resources to be able to be sure that Crossplane will not make any enexpected changes to the external resource.

I'm open to renaming the condition if there is a better option.

This build will fail until crossplane/crossplane#7294 is merged, at which point go.mod needs to be updated to the new version.

Fixes #939

I have:

Need help with this checklist? See the cheat sheet.

@bobh66 bobh66 requested a review from a team as a code owner April 10, 2026 12:28
@bobh66 bobh66 requested a review from adamwg April 10, 2026 12:28
@bobh66 bobh66 marked this pull request as draft April 10, 2026 12:28
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

📝 Walkthrough

Walkthrough

The reconciler now marks enriched status conditions alongside base reconcile states. Each reconciliation outcome (paused, success, failure, update requested/restricted) is accompanied by a specific condition reflecting its details. Test expectations are updated to validate these enhanced conditions across scenarios.

Changes

Cohort / File(s) Summary
Reconciler Status Condition Enrichment
pkg/reconciler/managed/reconciler.go
Updated status marking to include additional conditions: PausedUnknown() for paused reconciles, ObserveMatched() for up-to-date resources, UpdateRestricted() with diff message for policy-blocked updates, UpdateFailed() with diff message for failed updates, and UpdateRequested() with diff message for successful update requests.
Legacy Test Expectations
pkg/reconciler/managed/reconciler_legacy_test.go
Updated test scenarios to expect additional conditions alongside base reconcile states: ObserveMatched() for no-op successes, PausedUnknown() for paused states, UpdateRequested() with diff message for successful updates, UpdateFailed() for update errors, and UpdateRestricted() for observe-only cases. Mock observation now includes diff details.
Modern Test Expectations
pkg/reconciler/managed/reconciler_modern_test.go
Updated test expectations to include scenario-specific conditions: ObserveMatched() for no-op reconciles, UpdateRequested() with diff message for successful updates, UpdateFailed() for update failures, PausedUnknown() for pause-resume flows, and UpdateRestricted() for observe-only modes. Mock observation similarly includes diff details.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement UpToDate condition in managed reconciler' accurately describes the main change and is well under the 72-character limit at 50 characters.
Linked Issues check ✅ Passed The PR implements the core requirement from issue #939 by updating the reconciler to mark additional conditions including ObserveMatched (when resource is up-to-date), UpdateRestricted, UpdateFailed, and UpdateRequested with observation diffs.
Out of Scope Changes check ✅ Passed All changes are focused on implementing the UpToDate condition feature: reconciler status-condition marking updates and corresponding test updates across legacy and modern test suites.
Breaking Changes ✅ Passed Pull request modifies only internal implementation details within the Reconcile() method. All exported public APIs, method signatures, types, functions, and interfaces remain unchanged.
Description check ✅ Passed The description clearly explains the new UpToDate condition feature, its five scenarios, and why it matters for resource imports.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
pkg/reconciler/managed/reconciler_modern_test.go (2)

1787-1787: Please remove duplicated .WithObservedGeneration(42) chaining.

Tiny cleanup, but this looks accidental and adds noise during future maintenance.

Proposed cleanup
- want.SetConditions(xpv2.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42), xpv2.UpdateRestricted().WithObservedGeneration(42))
+ want.SetConditions(xpv2.ReconcileSuccess().WithObservedGeneration(42), xpv2.UpdateRestricted().WithObservedGeneration(42))

- want.SetConditions(xpv2.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42), xpv2.UpdateRequested().WithObservedGeneration(42).WithMessage("this is a diff"))
+ want.SetConditions(xpv2.ReconcileSuccess().WithObservedGeneration(42), xpv2.UpdateRequested().WithObservedGeneration(42).WithMessage("this is a diff"))

Also applies to: 1976-1976

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/reconciler/managed/reconciler_modern_test.go` at line 1787, Remove the
accidental duplicate chaining of .WithObservedGeneration(42) when setting
conditions: in the want.SetConditions call that uses
xpv2.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42)
and the analogous call around line 1976, keep only a single
.WithObservedGeneration(42) on each condition (e.g.,
xpv2.ReconcileSuccess().WithObservedGeneration(42) and
xpv2.UpdateRestricted().WithObservedGeneration(42)).

1262-1262: Could we add one failure-path case that asserts diff-backed UpdateFailed messaging?

Quick clarification: was it intentional not to validate message propagation on UpdateFailed when ExternalObservation.Diff is present? Adding that one case would keep failure conditions as actionable as the success-path coverage you added.

As per coding guidelines, **/pkg/reconciler/**: Conditions must be actionable for users (not developers), stable/deterministic, with proper Type/Reason/Message format.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/reconciler/managed/reconciler_modern_test.go` at line 1262, Add a
failing-path table test in reconciler_modern_test.go that mirrors the
success-path diff-backed case but asserts UpdateFailed includes the diff text:
create a test entry that sets the ExternalObservation with Diff (e.g.,
ExternalObservation{Diff: "some diff"}), simulate the update error (use errBoom
and wrap with errReconcileUpdate), and set want.SetConditions to expect
xpv2.ReconcileError(...).WithObservedGeneration(42) and
xpv2.UpdateFailed().WithObservedGeneration(42) whose Message contains both the
diff and the wrapped error text; place this entry alongside the existing cases
so the test asserts message propagation for UpdateFailed when
ExternalObservation.Diff is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/reconciler/managed/reconciler.go`:
- Around line 942-945: The new UpToDate condition is only set in some branches
(via status.MarkConditions(xpv2.ReconcilePaused(), xpv2.PausedUnknown()) then
r.client.Status().Update) leaving stale UpToDate state on other error paths;
update the reconcile flow so a fresh UpToDate condition is computed and applied
on every status update path (including pre-Observe errors and post-Observe
failures) — e.g., derive a deterministic UpToDate condition before returning
from Reconcile functions (where Observe() is called) and ensure every call that
uses r.client.Status().Update (including the branches around Observe(),
Create/Update/Delete error returns referenced near status.MarkConditions and the
other noted locations) includes that computed condition (or sets UpToDate to
Unknown with an appropriate reason/message when Observe did not complete) so
status is always consistent for users.
- Line 1459: Replace the raw cmp.Diff from ExternalObservation.Diff being
embedded in the user-facing condition (via status.MarkConditions(...
xpv2.UpdateRestricted().WithMessage(observation.Diff) ...)) with a short,
stable, actionable message (e.g., "update restricted: external changes detected"
or similar) so conditions remain deterministic; instead write observation.Diff
to debug/change logs using the reconciler logger (e.g., r.log.Debug/Info) or a
dedicated change log entry. Update all occurrences where observation.Diff is
used as a condition message (the status.MarkConditions calls around
ReconcileSuccess() / UpdateRestricted()) to use the stable message and emit the
raw diff to logs only.

---

Nitpick comments:
In `@pkg/reconciler/managed/reconciler_modern_test.go`:
- Line 1787: Remove the accidental duplicate chaining of
.WithObservedGeneration(42) when setting conditions: in the want.SetConditions
call that uses
xpv2.ReconcileSuccess().WithObservedGeneration(42).WithObservedGeneration(42)
and the analogous call around line 1976, keep only a single
.WithObservedGeneration(42) on each condition (e.g.,
xpv2.ReconcileSuccess().WithObservedGeneration(42) and
xpv2.UpdateRestricted().WithObservedGeneration(42)).
- Line 1262: Add a failing-path table test in reconciler_modern_test.go that
mirrors the success-path diff-backed case but asserts UpdateFailed includes the
diff text: create a test entry that sets the ExternalObservation with Diff
(e.g., ExternalObservation{Diff: "some diff"}), simulate the update error (use
errBoom and wrap with errReconcileUpdate), and set want.SetConditions to expect
xpv2.ReconcileError(...).WithObservedGeneration(42) and
xpv2.UpdateFailed().WithObservedGeneration(42) whose Message contains both the
diff and the wrapped error text; place this entry alongside the existing cases
so the test asserts message propagation for UpdateFailed when
ExternalObservation.Diff is present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0b380076-1f09-448e-b595-6809a6d09675

📥 Commits

Reviewing files that changed from the base of the PR and between d99a3b8 and 3e3747f.

📒 Files selected for processing (3)
  • pkg/reconciler/managed/reconciler.go
  • pkg/reconciler/managed/reconciler_legacy_test.go
  • pkg/reconciler/managed/reconciler_modern_test.go

Comment on lines +942 to 945
status.MarkConditions(xpv2.ReconcilePaused(), xpv2.PausedUnknown())
// if the pause annotation is removed or the management policies changed, we will have a chance to reconcile
// again and resume and if status update fails, we will reconcile again to retry to update the status
return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Could we prevent stale UpToDate conditions on the other error paths too?

Thanks for wiring this in. Right now the new condition is only updated in these branches, so any pre-observe error or a later failure after a fresh Observe() can leave the previous UpToDate condition on the object. That can make users see an old ObserveMatched/Update* result even though the latest reconcile never reached that conclusion. Would it make sense to compute a fresh UpToDate condition once per reconcile and include it in every subsequent status update, or explicitly mark it Unknown when observe never completes?

As per coding guidelines, **/pkg/reconciler/**: "Conditions must be actionable for users (not developers), stable/deterministic, with proper Type/Reason/Message format."

Also applies to: 1439-1440, 1459-1459, 1478-1478, 1509-1509

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/reconciler/managed/reconciler.go` around lines 942 - 945, The new
UpToDate condition is only set in some branches (via
status.MarkConditions(xpv2.ReconcilePaused(), xpv2.PausedUnknown()) then
r.client.Status().Update) leaving stale UpToDate state on other error paths;
update the reconcile flow so a fresh UpToDate condition is computed and applied
on every status update path (including pre-Observe errors and post-Observe
failures) — e.g., derive a deterministic UpToDate condition before returning
from Reconcile functions (where Observe() is called) and ensure every call that
uses r.client.Status().Update (including the branches around Observe(),
Create/Update/Delete error returns referenced near status.MarkConditions and the
other noted locations) includes that computed condition (or sets UpToDate to
Unknown with an appropriate reason/message when Observe did not complete) so
status is always consistent for users.

Comment thread pkg/reconciler/managed/reconciler.go
Signed-off-by: Bob Haddleton <bob.haddleton@nokia.com>
bobh66 added 3 commits April 14, 2026 14:58
Signed-off-by: Bob Haddleton <bob.haddleton@nokia.com>
Signed-off-by: Bob Haddleton <bob.haddleton@nokia.com>
Copy link
Copy Markdown
Member

@jbw976 jbw976 left a comment

Choose a reason for hiding this comment

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

Thanks for driving this @bobh66. A couple meta questions in addition to the review comments:

record.Event(managed, event.Warning(reasonCannotPublish, err))
status.MarkConditions(xpv2.ReconcileError(err))

return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this path will not get the status condition UpdateRequested(), even though the update was successfully requested.

what do you think about always calling status.MarkConditions(xpv2.UpdateRequested().WithMessage(observation.Diff)) immediately after the update request is successful, e.g. around line 1482-1488? that way, we have the condition set no matter what path gets taken later on.

that would also mean to remove the setting of it on line 1509, because we've already done it.

it's my understanding that calling status.MarkCondtions() multiple times is safe as it only replaces matching condition types, it won't remove any conditions: https://github.com/crossplane/crossplane/blob/main/apis/core/v2/condition.go#L190-L193

so this:

status.MarkConditions(xpv2.ReconcileSuccess(), xpv2.UpdateRequested().WithMessage(observation.Diff))

should be the same as:

status.MarkConditions(xpv2.UpdateRequested().WithMessage(observation.Diff))

# then later on...
status.MarkConditions(xpv2.ReconcileSuccess())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That works - I'll make the change.

reconcileAfter := r.pollIntervalHook(managed, r.pollInterval)
log.Debug("Skipping update due to managementPolicies. Reconciliation succeeded", "requeue-after", time.Now().Add(reconcileAfter))
status.MarkConditions(xpv2.ReconcileSuccess())
status.MarkConditions(xpv2.ReconcileSuccess(), xpv2.UpdateRestricted().WithMessage(observation.Diff))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what are we thinking for the size of these diffs and limits on k8s conditions fields? i think metav1.Condition.Message has a limit of 32KB: https://github.com/kubernetes/apimachinery/blob/master/pkg/apis/meta/v1/types.go#L1664

should we consider truncating this diff to be safe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Probably a good idea.

@bobh66
Copy link
Copy Markdown
Contributor Author

bobh66 commented Apr 15, 2026

Thanks for driving this @bobh66. A couple meta questions in addition to the review comments:

* what does this look like in practice? e.g. do you have an example of a `kubectl describe` with these conditions added, especially with a diff?

I have not been able to test this with a real provider yet. The API refactoring that moved the common APIs to c/c caused a lot of downstream fallout that makes pulling these changes into a provider for testing a lot more work. I'm hoping to get it working in provider-kubernetes but it's a lot more effort than I had expected.

* have we verified that providers are setting the diff field in practice and we'll typically have good data to show? e.g. a couple code paths in upjet that may be relevant that are not setting the ExternalObservation.Diff field:
  
  * https://github.com/crossplane/upjet/blob/main/pkg/controller/external_tfpluginfw.go#L706-L711
  * https://github.com/crossplane/upjet/blob/main/pkg/controller/external.go#L358-L362

I have not validated this, and indeed even provider-kubernetes does not supply the diff info. We could leave it out but I think it's the direction we want/need to go, so I'd prefer to support it and let the providers catch up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose the ResourceUpToDate status from Observe in the MR status/conditions

3 participants