Skip to content
Open
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
40 changes: 35 additions & 5 deletions pkg/reconciler/managed/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ const (
errUpdateCriticalAnnotations = "cannot update critical annotations"
)

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 +286,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
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]interface{}{
"metadata": map[string]any{
"annotations": a,
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we be more selective in what's being patched here, i.e., not all the annotations on the MR are the critical ones and we would only like to manage the critical annotations by this manager? This will probably not be an issue as this manager will not have an opinion on "non-critical" annotations and their respective managers will dictate their values. But one potential issue is when the other manager (who should really be owning the annotation) actually wants to delete the annotation. The managed.crossplane.io/critical-annotation-updater will still be owning the annotation. So the critical-annotation-updater had better not own non-critical ones...

Copy link
Author

Choose a reason for hiding this comment

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

I think it would be a reasonable addition for this function to have knowledge about what is a "critical annotation". Currently that comes from the execution order of the reconciler, this way we would make it explicit.
From what I can see, the ciritcal annotations should be:

  • crossplane.io/external-name
  • crossplane.io/external-create-pending
  • crossplane.io/external-create-succeeded
  • crossplane.io/external-create-failed

Am I missing any?

Copy link
Author

Choose a reason for hiding this comment

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

I've added a very naive approach in 11e2136

},
}

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like we patch all annotations here.
Just wondering how does this interact with the field ownerships of annotations that XP does not manage (like a custom annotation set by a user or some other controllers, tooling etc), and whether it is a thing to worry about regarding server-side apply etc. Would we cause a race regarding field ownerships?

Copy link
Author

Choose a reason for hiding this comment

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

Copy link
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
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
22 changes: 11 additions & 11 deletions pkg/reconciler/managed/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,12 +543,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{"getcalled": "true"})
return nil
}
objectReturnedByGet := &fake.LegacyManaged{}
setLabels(objectReturnedByGet)
setAnnotations(objectReturnedByGet)

cases := map[string]struct {
reason string
Expand All @@ -559,8 +559,8 @@ 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)),
Expand All @@ -576,8 +576,8 @@ 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{},
Expand All @@ -590,8 +590,8 @@ func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
"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)),
Expand All @@ -610,8 +610,8 @@ 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{},
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 @@ -1408,6 +1408,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
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
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
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 +1411 to +1424
Copy link
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