Skip to content

Avoid setting tags if there are no changes, prevent conflict on every resource creation#476

Open
chlunde wants to merge 1 commit intocrossplane:mainfrom
chlunde:main
Open

Avoid setting tags if there are no changes, prevent conflict on every resource creation#476
chlunde wants to merge 1 commit intocrossplane:mainfrom
chlunde:main

Conversation

@chlunde
Copy link
Copy Markdown

@chlunde chlunde commented Mar 7, 2025

  • Avoid setting tags if there are no changes, prevent conflict on every resource creation

I've observed this error message on every resource creation:

  2025-03-06T09:21:58+01:00       DEBUG   provider-aws    Cannot initialize managed resource      {"controller": "managed/ec2.aws.upbound.io/v1beta1, kind=securitygroup",
      "request": {"name":"example4"},
      "uid": "6110a508-2be7-470c-8d00-10302dc9c270",
      "version": "101685",
      "external-name": "",
      "error": "Operation cannot be fulfilled on securitygroups.ec2.aws.upbound.io \"example4\": the object has been modified; please apply your changes to the latest version and try again"}

By avoiding this unneccessary unconditional Update API call, we get rid of this error message.

Description of your changes

Fixes #

I have:

  • Read and followed Upjet's contribution process.
  • Run make reviewable to ensure this PR is ready for review.
  • Added backport release-x.y labels to auto-backport this PR if necessary.

How has this code been tested

… resource creation

I've observed this error message on every resource creation:

  2025-03-06T09:21:58+01:00       DEBUG   provider-aws    Cannot initialize managed resource      {"controller": "managed/ec2.aws.upbound.io/v1beta1, kind=securitygroup",
      "request": {"name":"example4"},
      "uid": "6110a508-2be7-470c-8d00-10302dc9c270",
      "version": "101685",
      "external-name": "",
      "error": "Operation cannot be fulfilled on securitygroups.ec2.aws.upbound.io \"example4\": the object has been modified; please apply your changes to the latest version and try again"}

By avoiding this unneccessary unconditional Update API call, we get rid of this error message.

Signed-off-by: Carl Henrik Lunde <chlunde@ifi.uio.no>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

These changes add change-detection to external tag updates by modifying setExternalTagsWithPaved to return a boolean indicating whether tags were modified, introduce tagsUpToDate to compare desired vs. current tags, and update Initialize to short-circuit when no changes occur. Tests were updated to assert the new return shape and cover no-change/change scenarios.

Changes

Cohort / File(s) Summary
Tag Change-Tracking Logic
pkg/config/resource.go
Changed setExternalTagsWithPaved signature to ([]byte, bool, error) and implemented change detection; added tagsUpToDate(tags map[string]*string, paved *fieldpath.Paved, tagField string) bool; Initialize now checks the returned changed flag and skips JSON unmarshalling when no change.
Tests: behavior and expectations
pkg/config/resource_test.go
Updated tests to import metav1, initialize fake managed objects' metadata, capture the new changed return, and expanded test cases to SuccessfulNew, SuccessfulChange, and SuccessfulNoChange verifying change detection and error messages.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title accurately describes the main change: avoiding unnecessary tag updates when no changes occur to prevent API conflicts during resource creation. Reduce title to under 72 characters. Consider: 'Avoid unnecessary tag updates to prevent API conflicts' (53 chars).
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is related to the changeset, explaining the motivation (preventing conflict errors) and the high-level approach (avoiding unconditional tag updates).
Configuration Api Breaking Changes ✅ Passed Changes only modify private functions in pkg/config/resource.go; public API surface remains unchanged.
Generated Code Manual Edits ✅ Passed The PR modifies hand-written source files with no generated code markers and no zz_*.go files exist in the repository.
Template Breaking Changes ✅ Passed PR does not modify any controller template files matching pkg/controller/external*.go pattern; changes are isolated to pkg/config/ configuration files.

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

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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

@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 (3)
pkg/config/resource.go (1)

392-399: Consider wrapping the new error returns with context per the project's guidelines.

Both new return nil, false, err paths propagate errors from paved.SetValue and paved.MarshalJSON without any context, which makes it harder for provider developers to understand what failed. Adding meaningful wraps would align with the project's error-messaging guidelines.

♻️ Suggested wrapping
-    if err := paved.SetValue(tagField, tags); err != nil {
-        return nil, false, err
+    if err := paved.SetValue(tagField, tags); err != nil {
+        return nil, false, errors.Wrap(err, "cannot set external tags on paved object")
     }
     pavedByte, err := paved.MarshalJSON()
     if err != nil {
-        return nil, false, err
+        return nil, false, errors.Wrap(err, "cannot marshal paved object after setting external tags")
     }

As per coding guidelines: "Wrap errors with context using patterns like: errors.Wrap(err, "cannot configure resource")" and "Ensure all error messages are meaningful to provider developers."

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

In `@pkg/config/resource.go` around lines 392 - 399, The error returns from
paved.SetValue and paved.MarshalJSON lack context; update the error handling in
the block using paved.SetValue(tagField, tags) and paved.MarshalJSON() to wrap
the underlying errors with descriptive messages (e.g., using errors.Wrap or
fmt.Errorf("%w", ...)) such as "cannot set tag field <tagField>" and "cannot
marshal paved JSON" so the returned errors provide clear context for provider
developers while preserving the original error.
pkg/config/resource_test.go (2)

29-71: Consider adding a "no-op" test case to cover the new early-return path.

Both existing cases start with a fresh fake.Managed (no tags pre-set), so tagsUpToDate always returns false, changed is always true, and kube.Update is always called. The new code path — where tags are already up-to-date and kube.Update is not called — isn't exercised at all in TestTaggerInitialize.

A quick way to cover this is to add a case where the managed object is pre-paved with the correct tags, and assert that kube.Update is never invoked:

"SuccessfulNoChange": {
    args: args{
        mg: func() xpresource.Managed {
            // pre-set managed object with correct external tags so tagsUpToDate == true
            mg := &fake.Managed{ObjectMeta: metav1.ObjectMeta{Name: "name"}}
            // ... pave and set tags on mg before returning ...
            return mg
        }(),
        kube: &test.MockClient{
            MockUpdate: test.NewMockUpdateFn(errors.New("Update must not be called when tags are up to date")),
        },
    },
    want: want{err: nil},
},

Would you be open to adding this? Happy to help draft it if useful!

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

In `@pkg/config/resource_test.go` around lines 29 - 71, Add a "no-op" test case to
TestTaggerInitialize that verifies the early-return path when tags are already
up-to-date: create a new case named "SuccessfulNoChange" where the mg argument
is constructed via a function literal that pre-sets the managed object's
external tags so tagsUpToDate(...) will return true, use NewTagger(...) and pass
a test.MockClient whose MockUpdate is set to a MockUpdateFn that returns an
error like "Update must not be called when tags are up to date" (so the test
fails if kube.Update is invoked), and expect nil error from
tagger.Initialize(...); this exercises the path in Initialize where
changed==false and Update is not called.

159-172: The assertion ordering looks good — one optional tweak.

The current flow (errchangedpavedString) is the right order. One minor note: using t.Fatalf for all three assertions means the first failure stops the test without showing the other two. For test assertions that are independent, t.Errorf would give a fuller picture on failure. Totally optional and consistent with the surrounding file style, so take it or leave it.

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

In `@pkg/config/resource_test.go` around lines 159 - 172, The test uses t.Fatalf
for three independent assertions inside the t.Run for setExternalTagsWithPaved,
which aborts the subtest on the first failure; change the assertions for the
independent checks (the changed comparison using gotChanged / tc.want.changed
and the pavedString comparison using string(gotByte) / tc.want.pavedString with
cmp.Diff) from t.Fatalf to t.Errorf so multiple failures are reported, while you
may keep the initial error assertion (comparing gotErr) as t.Fatalf if you want
to stop on unexpected errors.
🤖 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/config/resource.go`:
- Around line 370-377: The current logic only checks subset equality in
tagsUpToDate and then overwrites the whole tag field in
setExternalTagsWithPaved, which discards user tags when any system tag must be
updated; change setExternalTagsWithPaved to merge instead of replace: read the
existing tag map from the paved (using the same tagField/get method), copy its
entries, overwrite or set the system tag keys from the desired tags map (the
`tags` param) into that copy, and call paved.SetValue(tagField, mergedMap) so
user tags are preserved while system tags are updated; keep tagsUpToDate as the
quick subset check.
- Around line 370-378: tagsUpToDate currently dereferences values in the tags
map without guarding nil and ignores the error from paved.GetStringObject;
update the function (tagsUpToDate) to first call paved.GetStringObject(tagField)
and keep the current behavior when an error occurs but add an explanatory
comment that a non-existent/non-string field should be treated as empty, and
before comparing each entry ensure the pointer v is not nil (treat nil as not
up-to-date and return false) instead of dereferencing *v unguarded; reference
symbols: tagsUpToDate, tags map[string]*string, paved.GetStringObject, curTags,
and v.

---

Nitpick comments:
In `@pkg/config/resource_test.go`:
- Around line 29-71: Add a "no-op" test case to TestTaggerInitialize that
verifies the early-return path when tags are already up-to-date: create a new
case named "SuccessfulNoChange" where the mg argument is constructed via a
function literal that pre-sets the managed object's external tags so
tagsUpToDate(...) will return true, use NewTagger(...) and pass a
test.MockClient whose MockUpdate is set to a MockUpdateFn that returns an error
like "Update must not be called when tags are up to date" (so the test fails if
kube.Update is invoked), and expect nil error from tagger.Initialize(...); this
exercises the path in Initialize where changed==false and Update is not called.
- Around line 159-172: The test uses t.Fatalf for three independent assertions
inside the t.Run for setExternalTagsWithPaved, which aborts the subtest on the
first failure; change the assertions for the independent checks (the changed
comparison using gotChanged / tc.want.changed and the pavedString comparison
using string(gotByte) / tc.want.pavedString with cmp.Diff) from t.Fatalf to
t.Errorf so multiple failures are reported, while you may keep the initial error
assertion (comparing gotErr) as t.Fatalf if you want to stop on unexpected
errors.

In `@pkg/config/resource.go`:
- Around line 392-399: The error returns from paved.SetValue and
paved.MarshalJSON lack context; update the error handling in the block using
paved.SetValue(tagField, tags) and paved.MarshalJSON() to wrap the underlying
errors with descriptive messages (e.g., using errors.Wrap or fmt.Errorf("%w",
...)) such as "cannot set tag field <tagField>" and "cannot marshal paved JSON"
so the returned errors provide clear context for provider developers while
preserving the original error.

Comment on lines +370 to +377
func tagsUpToDate(tags map[string]*string, paved *fieldpath.Paved, tagField string) bool {
curTags, _ := paved.GetStringObject(tagField)
for k, v := range tags {
if curTags[k] != *v {
return false
}
}
return true
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Worth confirming: the subset-check is intentional.

tagsUpToDate only verifies that the three desired system tags are present with the correct values — it doesn't require the tag field to contain exactly those three keys. This creates a subtle asymmetry:

  • System tags already correct + extra user tags presenttagsUpToDate = true → update skipped → user tags are preserved
  • Any system tag wrong + extra user tags presenttagsUpToDate = falsepaved.SetValue(tagField, tags) replaces the entire field with only the three system tags → extra user tags are lost ⚠️

The first case is an improvement over the old behavior (which always replaced). But the second case is unchanged and still discards user-defined tags when a system tag needs updating. Is this an acceptable trade-off, or should setExternalTagsWithPaved be doing a merge instead of a full replace when it does write? Happy to discuss the intent here — just want to make sure this asymmetry is deliberate.

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

In `@pkg/config/resource.go` around lines 370 - 377, The current logic only checks
subset equality in tagsUpToDate and then overwrites the whole tag field in
setExternalTagsWithPaved, which discards user tags when any system tag must be
updated; change setExternalTagsWithPaved to merge instead of replace: read the
existing tag map from the paved (using the same tagField/get method), copy its
entries, overwrite or set the system tag keys from the desired tags map (the
`tags` param) into that copy, and call paved.SetValue(tagField, mergedMap) so
user tags are preserved while system tags are updated; keep tagsUpToDate as the
quick subset check.

Comment on lines +370 to +378
func tagsUpToDate(tags map[string]*string, paved *fieldpath.Paved, tagField string) bool {
curTags, _ := paved.GetStringObject(tagField)
for k, v := range tags {
if curTags[k] != *v {
return false
}
}
return true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Two small safety concerns worth addressing in tagsUpToDate.

  1. Nil pointer dereference (Line 373): *v will panic if any value in the tags map is nil. The current call site always uses ptr.To(...), so it's safe today, but the function's signature accepts map[string]*string without that guarantee. Adding a nil guard here is cheap insurance:
🛡️ Suggested nil guard
 for k, v := range tags {
+    if v == nil {
+        return false
+    }
     if curTags[k] != *v {
         return false
     }
 }
  1. Silently discarded error (Line 371): curTags, _ := paved.GetStringObject(tagField) — dropping the error is benign here (a missing or non-string field yields an empty map, which correctly causes the function to return false), but it makes it harder to debug unexpected field types in future. At least a comment explaining the intentional discard would help readers understand the invariant.
📝 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
func tagsUpToDate(tags map[string]*string, paved *fieldpath.Paved, tagField string) bool {
curTags, _ := paved.GetStringObject(tagField)
for k, v := range tags {
if curTags[k] != *v {
return false
}
}
return true
}
func tagsUpToDate(tags map[string]*string, paved *fieldpath.Paved, tagField string) bool {
curTags, _ := paved.GetStringObject(tagField)
for k, v := range tags {
if v == nil {
return false
}
if curTags[k] != *v {
return false
}
}
return true
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/config/resource.go` around lines 370 - 378, tagsUpToDate currently
dereferences values in the tags map without guarding nil and ignores the error
from paved.GetStringObject; update the function (tagsUpToDate) to first call
paved.GetStringObject(tagField) and keep the current behavior when an error
occurs but add an explanatory comment that a non-existent/non-string field
should be treated as empty, and before comparing each entry ensure the pointer v
is not nil (treat nil as not up-to-date and return false) instead of
dereferencing *v unguarded; reference symbols: tagsUpToDate, tags
map[string]*string, paved.GetStringObject, curTags, and v.

Copy link
Copy Markdown

@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.

🧹 Nitpick comments (2)
pkg/config/resource_test.go (2)

42-59: Thanks for updating the test fixtures with ObjectMeta — this ensures the external tags are non-empty.

I appreciate the thoughtfulness here. Without a Name, the external tags could be empty strings that match the zero-value of a missing paved field, which would cause tagsUpToDate to return true and skip the update entirely — making the "Successful" test no longer exercise the update path.

Could I suggest adding a test case for the no-change path through Initialize? Currently, the early-return at Lines 358–360 of resource.go is only covered indirectly via TestSetExternalTagsWithPaved. A test where the fake.Managed already has correct tags in its paved state — and the MockUpdate is wired to fail if called — would give more confidence that the unnecessary API call is truly prevented. That's the core value proposition of this PR, after all!

Something along these lines:

💡 Suggested test case for the no-change path
"NoChangeSkipsUpdate": {
    args: args{
        mg: func() xpresource.Managed {
            m := &fake.Managed{ObjectMeta: metav1.ObjectMeta{Name: "name"}}
            // Pre-populate the tags so they match what Initialize would set
            paved, _ := fieldpath.PaveObject(m)
            tags := map[string]*string{
                xpresource.ExternalResourceTagKeyKind:     ptr.To(m.GetObjectKind().GroupVersionKind().Kind),
                xpresource.ExternalResourceTagKeyName:     ptr.To(m.GetName()),
                xpresource.ExternalResourceTagKeyProvider: ptr.To(""),
            }
            _ = paved.SetValue("spec.forProvider.tags", tags)
            // Unmarshal back into the managed resource
            b, _ := paved.MarshalJSON()
            _ = json.Unmarshal(b, m)
            return m
        }(),
        kube: &test.MockClient{MockUpdate: test.NewMockUpdateFn(errors.New("update should not be called"))},
    },
    want: want{
        err: nil,
    },
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/config/resource_test.go` around lines 42 - 59, Add a new unit test case
"NoChangeSkipsUpdate" in resource_test.go that constructs a fake.Managed (with
ObjectMeta{Name: "name"}), pre-populates its paved "spec.forProvider.tags" to
exactly match what Initialize would set (the ExternalResourceTag keys for Kind,
Name, Provider), then uses test.MockClient{MockUpdate:
test.NewMockUpdateFn(errors.New("update should not be called"))} so any update
call fails the test; this verifies tagsUpToDate/Initialize short-circuits and
avoids calling kube.Update (mirroring TestSetExternalTagsWithPaved but asserting
no update is invoked). Ensure the test references Initialize/tagsUpToDate
behavior by building the paved object via fieldpath.PaveObject and marshaling
back into the managed resource before calling the code under test.

160-169: The changed flag assertion on Line 164 is direct and clear.

Minor note: the test.EquateErrors() option on Line 167 is a no-op when comparing strings (it's designed for error types), but it's harmless and consistent with the existing style in this file. No action needed — just flagging for awareness.

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

In `@pkg/config/resource_test.go` around lines 160 - 169, The cmp.Diff call
comparing expected pavedString to string(gotByte) uses test.EquateErrors() which
is a no-op for string comparisons; remove the unnecessary option to clean up the
assertion in the setExternalTagsWithPaved test (update the cmp.Diff invocation
that currently passes test.EquateErrors() so it just compares
tc.want.pavedString and string(gotByte)); keep the direct changed-flag assertion
using gotChanged as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/config/resource.go`:
- Around line 370-378: In tagsUpToDate, guard against nil pointer deref of map
values by checking v != nil before dereferencing (*v) and treat a nil v as
non-matching (return false) or handle per intended semantics; also avoid
silently dropping the error from paved.GetStringObject—either handle the error
or add an inline comment next to the call (GetStringObject) explaining that on
error we intentionally treat curTags as empty so the function returns false
(safe fallback).

---

Nitpick comments:
In `@pkg/config/resource_test.go`:
- Around line 42-59: Add a new unit test case "NoChangeSkipsUpdate" in
resource_test.go that constructs a fake.Managed (with ObjectMeta{Name: "name"}),
pre-populates its paved "spec.forProvider.tags" to exactly match what Initialize
would set (the ExternalResourceTag keys for Kind, Name, Provider), then uses
test.MockClient{MockUpdate: test.NewMockUpdateFn(errors.New("update should not
be called"))} so any update call fails the test; this verifies
tagsUpToDate/Initialize short-circuits and avoids calling kube.Update (mirroring
TestSetExternalTagsWithPaved but asserting no update is invoked). Ensure the
test references Initialize/tagsUpToDate behavior by building the paved object
via fieldpath.PaveObject and marshaling back into the managed resource before
calling the code under test.
- Around line 160-169: The cmp.Diff call comparing expected pavedString to
string(gotByte) uses test.EquateErrors() which is a no-op for string
comparisons; remove the unnecessary option to clean up the assertion in the
setExternalTagsWithPaved test (update the cmp.Diff invocation that currently
passes test.EquateErrors() so it just compares tc.want.pavedString and
string(gotByte)); keep the direct changed-flag assertion using gotChanged as-is.

@chlunde
Copy link
Copy Markdown
Author

chlunde commented Feb 18, 2026

Reproduction setup

Used crossplane/upjet-provider-template (hashicorp/null provider, no cloud credentials needed).

Changes to provider template

  1. Added custom Tagger initializer using "triggers" field in both config files:

    r.InitializerFns = append(r.InitializerFns, func(c client.Client) managed.Initializer {
        return ujconfig.NewTagger(c, "triggers")
    })
  2. Enabled r.UseAsync = true — enables async status updates that create realistic
    concurrency (like real cloud providers)

  3. Added time.Sleep(500 * time.Millisecond) in TerraformSetupBuilder — simulates
    slow provider setup (cloud API auth, etc.)

  4. Added replace github.com/crossplane/upjet/v2 => ../upjet to go.mod

  5. Regenerated controllers and deployed via make local-deploy

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.

1 participant