Skip to content
41 changes: 36 additions & 5 deletions pkg/reconciler/managed/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ const (
errUpdateCriticalAnnotations = "cannot update critical annotations"
)

//nolint:gochecknoglobals // this is a list of critical annotations that should be retried when updating, and is not expected to be modified at runtime.
var (
criticalAnnotations = []string{
meta.AnnotationKeyExternalCreateFailed,
meta.AnnotationKeyExternalCreatePending,
meta.AnnotationKeyExternalCreateSucceeded,
meta.AnnotationKeyExternalName,
}
)

// NameAsExternalName writes the name of the managed resource to
// the external name annotation field in order to be used as name of
// the external resource in provider.
Expand Down Expand Up @@ -277,15 +287,36 @@ func NewRetryingCriticalAnnotationUpdater(c client.Client) *RetryingCriticalAnno
// UpdateCriticalAnnotations updates (i.e. persists) the annotations of the
// supplied Object. It retries in the face of any API server error several times
// in order to ensure annotations that contain critical state are persisted.
// Pending changes to the supplied Object's spec, status, or other metadata
// might get reset to their current state according to the API server, e.g. in
// case of a conflict error.
// Only annotations will be updated as part of this operation, other fields of the
// supplied Object will not be modified.
func (u *RetryingCriticalAnnotationUpdater) UpdateCriticalAnnotations(ctx context.Context, o client.Object) error {
a := o.GetAnnotations()
a := make(map[string]string)
for _, k := range criticalAnnotations {
if v, ok := o.GetAnnotations()[k]; ok {
a[k] = v
}
}

if len(a) == 0 {
// No critical annotations to update.
return nil
}

err := retry.OnError(retry.DefaultRetry, func(err error) bool {
return !errors.Is(err, context.Canceled)
}, func() error {
err := u.client.Update(ctx, o)
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.

I like this update operation is being replaced by SSA.

patchMap := map[string]any{
"metadata": map[string]any{
"annotations": a,
Comment thread
twobiers marked this conversation as resolved.
},
}

patchData, err := json.Marshal(patchMap)
if err != nil {
return err
}

err = u.client.Patch(ctx, o, client.RawPatch(types.MergePatchType, patchData), client.FieldOwner(fieldOwnerAPISimpleRefResolver), client.ForceOwnership)
Comment thread
twobiers marked this conversation as resolved.
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.

We had better use a different manager name than managed.crossplane.io/api-simple-reference-resolver as the annotations have nothing to do with the API resolver. Maybe something like: managed.crossplane.io/critical-annotation-updater?

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.

Would it make sense for the MR reconciler to just use one manager name, regardless of operation?

Possibly too late if we're already using api-simple-reference-resolver for some.

if kerrors.IsConflict(err) {
if getErr := u.client.Get(ctx, client.ObjectKeyFromObject(o), o); getErr != nil {
return getErr
Expand Down
58 changes: 41 additions & 17 deletions pkg/reconciler/managed/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,12 +551,12 @@ func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
o client.Object
}

setLabels := func(obj client.Object) error {
obj.SetLabels(map[string]string{"getcalled": "true"})
setAnnotations := func(obj client.Object) error {
obj.SetAnnotations(map[string]string{"crossplane.io/external-name": "my-external-name"})
return nil
}
objectReturnedByGet := &fake.LegacyManaged{}
setLabels(objectReturnedByGet)
setAnnotations(objectReturnedByGet)

cases := map[string]struct {
reason string
Expand All @@ -567,14 +567,18 @@ func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
"UpdateConflictGetError": {
reason: "We should return any error we encounter getting the supplied object",
c: &test.MockClient{
MockGet: test.NewMockGetFn(errBoom, setLabels),
MockUpdate: test.NewMockUpdateFn(kerrors.NewConflict(schema.GroupResource{
MockGet: test.NewMockGetFn(errBoom, setAnnotations),
MockPatch: test.NewMockPatchFn(kerrors.NewConflict(schema.GroupResource{
Group: "foo.com",
Resource: "bars",
}, "abc", errBoom)),
},
args: args{
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
want: want{
err: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
Expand All @@ -584,28 +588,40 @@ func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
"UpdateError": {
reason: "We should return any error we encounter updating the supplied object",
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil, setLabels),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockGet: test.NewMockGetFn(nil, setAnnotations),
MockPatch: test.NewMockPatchFn(errBoom),
},
args: args{
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
want: want{
err: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
},
"SuccessfulGetAfterAConflict": {
reason: "A successful get after a conflict should not hide the conflict error and prevent retries",
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil, setLabels),
MockUpdate: test.NewMockUpdateFn(kerrors.NewConflict(schema.GroupResource{
MockGet: test.NewMockGetFn(nil, setAnnotations),
MockPatch: test.NewMockPatchFn(kerrors.NewConflict(schema.GroupResource{
Group: "foo.com",
Resource: "bars",
}, "abc", errBoom)),
},
args: args{
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
want: want{
err: errors.Wrap(kerrors.NewConflict(schema.GroupResource{
Expand All @@ -618,15 +634,23 @@ func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
"Success": {
reason: "We should return without error if we successfully update our annotations",
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil, setLabels),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockGet: test.NewMockGetFn(nil, setAnnotations),
MockPatch: test.NewMockPatchFn(errBoom),
},
args: args{
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
want: want{
err: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
o: &fake.LegacyManaged{},
o: &fake.LegacyManaged{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{"crossplane.io/external-name": "my-external-name"},
},
},
},
},
}
Expand Down
15 changes: 15 additions & 0 deletions pkg/reconciler/managed/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,21 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (resu
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
}

if observation.ResourceExists {
// When a resource exists or is just created, it might have received
// a non-deterministic external name after its creation, which we need to persist.
// We do this by updating the critical annotations.
// This is needed because some resources might not receive an external-name directly
// after the creation, but later as part of an asynchronous process.
// When Crossplane supports asynchronous creation of resources natively, this logic
// might not be needed anymore and can be revisited.
if err := r.managed.UpdateCriticalAnnotations(ctx, managed); err != nil {
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.

a thing to consider (possible nit):
most reconciliation loops will enter this codepath regardless of a need to update critical annotations. I wonder if this one is no-op in terms of k8s API access or brings some extra load on the apiserver.

Especially in async creations with long-running creation times, currently (with no native async support) the external clients return observation.ResourceExists as true to avoid further actions for the observations during creation of the resource.

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.

Perhaps we should make writing these annotations optional. I'd suggest making them opt-out since that's the safest path. The option would be specified by the provider author - so they can disable these annotations if they know for sure they're not needed (i.e. naming is deterministic, API is strongly consistent). I remember discussing this with someone recently, but can't find a tracking issue.

(If we do make them optional, I think we could do it pretty easily by injecting a no-op implementation of the annotation updater.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It should also be straightforward to keep a state in the reconciler whether a critical annotation was added/changed and depending on that update the annotations or perform a no-op.

On the other hand I'm wondering whether this kind of optimization is needed for "Critical" annotations. Shouldn't the priority be here to add those annotations? Otherwise it would leave room for errors.

log.Debug(errUpdateManagedAnnotations, "error", err)
record.Event(managed, event.Warning(reasonCannotUpdateManaged, errors.Wrap(err, errUpdateManagedAnnotations)))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedAnnotations)
}
}
Comment on lines +1413 to +1426
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.

As @lsviben explains here, upjet should not be relying on setting ResourceLateInitialized to get the critical annotations updated in the first place. The async mode implemented by upjet breaks the assumptions of the managed reconciler. I believe we need to first address this discrepancy between upjet and the managed reconciler...


if observation.ResourceLateInitialized && policy.ShouldLateInitialize() {
// Note that this update may reset any pending updates to the status of
// the managed resource from when it was observed above. This is because
Expand Down
Loading
Loading