Merge pull request #288 from crossplane/backport-283-to-release-0.15

[Backport release-0.15] Account for two different kinds of consistency issues
This commit is contained in:
Nic Cope 2021-09-07 14:45:00 -07:00 committed by GitHub
commit 5141d0bb35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 806 additions and 198 deletions

View File

@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"strings" "strings"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@ -31,9 +32,32 @@ import (
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
) )
// AnnotationKeyExternalName is the key in the annotations map of a resource for const (
// the name of the resource as it appears on provider's systems. // AnnotationKeyExternalName is the key in the annotations map of a
const AnnotationKeyExternalName = "crossplane.io/external-name" // resource for the name of the resource as it appears on provider's
// systems.
AnnotationKeyExternalName = "crossplane.io/external-name"
// AnnotationKeyExternalCreatePending is the key in the annotations map
// of a resource that indicates the last time creation of the external
// resource was pending (i.e. about to happen). Its value must be an
// RFC3999 timestamp.
AnnotationKeyExternalCreatePending = "crossplane.io/external-create-pending"
// AnnotationKeyExternalCreateSucceeded is the key in the annotations
// map of a resource that represents the last time the external resource
// was created successfully. Its value must be an RFC3339 timestamp,
// which can be used to determine how long ago a resource was created.
// This is useful for eventually consistent APIs that may take some time
// before the API called by Observe will report that a recently created
// external resource exists.
AnnotationKeyExternalCreateSucceeded = "crossplane.io/external-create-succeeded"
// AnnotationKeyExternalCreateFailed is the key in the annotations map
// of a resource that indicates the last time creation of the external
// resource failed. Its value must be an RFC3999 timestamp.
AnnotationKeyExternalCreateFailed = "crossplane.io/external-create-failed"
)
// Supported resources with all of these annotations will be fully or partially // Supported resources with all of these annotations will be fully or partially
// propagated to the named resource of the same kind, assuming it exists and // propagated to the named resource of the same kind, assuming it exists and
@ -245,6 +269,90 @@ func SetExternalName(o metav1.Object, name string) {
AddAnnotations(o, map[string]string{AnnotationKeyExternalName: name}) AddAnnotations(o, map[string]string{AnnotationKeyExternalName: name})
} }
// GetExternalCreatePending returns the time at which the external resource
// was most recently pending creation.
func GetExternalCreatePending(o metav1.Object) time.Time {
a := o.GetAnnotations()[AnnotationKeyExternalCreatePending]
t, err := time.Parse(time.RFC3339, a)
if err != nil {
return time.Time{}
}
return t
}
// SetExternalCreatePending sets the time at which the external resource was
// most recently pending creation to the supplied time.
func SetExternalCreatePending(o metav1.Object, t time.Time) {
AddAnnotations(o, map[string]string{AnnotationKeyExternalCreatePending: t.Format(time.RFC3339)})
}
// GetExternalCreateSucceeded returns the time at which the external resource
// was most recently created.
func GetExternalCreateSucceeded(o metav1.Object) time.Time {
a := o.GetAnnotations()[AnnotationKeyExternalCreateSucceeded]
t, err := time.Parse(time.RFC3339, a)
if err != nil {
return time.Time{}
}
return t
}
// SetExternalCreateSucceeded sets the time at which the external resource was
// most recently created to the supplied time.
func SetExternalCreateSucceeded(o metav1.Object, t time.Time) {
AddAnnotations(o, map[string]string{AnnotationKeyExternalCreateSucceeded: t.Format(time.RFC3339)})
}
// GetExternalCreateFailed returns the time at which the external resource
// recently failed to create.
func GetExternalCreateFailed(o metav1.Object) time.Time {
a := o.GetAnnotations()[AnnotationKeyExternalCreateFailed]
t, err := time.Parse(time.RFC3339, a)
if err != nil {
return time.Time{}
}
return t
}
// SetExternalCreateFailed sets the time at which the external resource most
// recently failed to create.
func SetExternalCreateFailed(o metav1.Object, t time.Time) {
AddAnnotations(o, map[string]string{AnnotationKeyExternalCreateFailed: t.Format(time.RFC3339)})
}
// ExternalCreateIncomplete returns true if creation of the external resource
// appears to be incomplete. We deem creation to be incomplete if the 'external
// create pending' annotation is the newest of all tracking annotations that are
// set (i.e. pending, succeeded, and failed).
func ExternalCreateIncomplete(o metav1.Object) bool {
pending := GetExternalCreatePending(o)
succeeded := GetExternalCreateSucceeded(o)
failed := GetExternalCreateFailed(o)
// If creation never started it can't be incomplete.
if pending.IsZero() {
return false
}
latest := succeeded
if failed.After(succeeded) {
latest = failed
}
return pending.After(latest)
}
// ExternalCreateSucceededDuring returns true if creation of the external
// resource that corresponds to the supplied managed resource succeeded within
// the supplied duration.
func ExternalCreateSucceededDuring(o metav1.Object, d time.Duration) bool {
t := GetExternalCreateSucceeded(o)
if t.IsZero() {
return false
}
return time.Since(t) < d
}
// AllowPropagation from one object to another by adding consenting annotations // AllowPropagation from one object to another by adding consenting annotations
// to both. // to both.
// Deprecated: This functionality will be removed soon. // Deprecated: This functionality will be removed soon.

View File

@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"testing" "testing"
"time"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -901,6 +902,284 @@ func TestSetExternalName(t *testing.T) {
} }
} }
func TestGetExternalCreatePending(t *testing.T) {
now := time.Now().Round(time.Second)
cases := map[string]struct {
o metav1.Object
want time.Time
}{
"ExternalCreatePendingExists": {
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreatePending: now.Format(time.RFC3339)}}},
want: now,
},
"NoExternalCreatePending": {
o: &corev1.Pod{},
want: time.Time{},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := GetExternalCreatePending(tc.o)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("GetExternalCreatePending(...): -want, +got:\n%s", diff)
}
})
}
}
func TestSetExternalCreatePending(t *testing.T) {
now := time.Now()
cases := map[string]struct {
o metav1.Object
t time.Time
want metav1.Object
}{
"SetsTheCorrectKey": {
o: &corev1.Pod{},
t: now,
want: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreatePending: now.Format(time.RFC3339)}}},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
SetExternalCreatePending(tc.o, tc.t)
if diff := cmp.Diff(tc.want, tc.o); diff != "" {
t.Errorf("SetExternalCreatePending(...): -want, +got:\n%s", diff)
}
})
}
}
func TestGetExternalCreateSucceeded(t *testing.T) {
now := time.Now().Round(time.Second)
cases := map[string]struct {
o metav1.Object
want time.Time
}{
"ExternalCreateTimeExists": {
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreateSucceeded: now.Format(time.RFC3339)}}},
want: now,
},
"NoExternalCreateTime": {
o: &corev1.Pod{},
want: time.Time{},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := GetExternalCreateSucceeded(tc.o)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("GetExternalCreateSucceeded(...): -want, +got:\n%s", diff)
}
})
}
}
func TestSetExternalCreateSucceeded(t *testing.T) {
now := time.Now()
cases := map[string]struct {
o metav1.Object
t time.Time
want metav1.Object
}{
"SetsTheCorrectKey": {
o: &corev1.Pod{},
t: now,
want: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreateSucceeded: now.Format(time.RFC3339)}}},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
SetExternalCreateSucceeded(tc.o, tc.t)
if diff := cmp.Diff(tc.want, tc.o); diff != "" {
t.Errorf("SetExternalCreateSucceeded(...): -want, +got:\n%s", diff)
}
})
}
}
func TestGetExternalCreateFailed(t *testing.T) {
now := time.Now().Round(time.Second)
cases := map[string]struct {
o metav1.Object
want time.Time
}{
"ExternalCreateFailedExists": {
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreateFailed: now.Format(time.RFC3339)}}},
want: now,
},
"NoExternalCreateFailed": {
o: &corev1.Pod{},
want: time.Time{},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := GetExternalCreateFailed(tc.o)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("GetExternalCreateFailed(...): -want, +got:\n%s", diff)
}
})
}
}
func TestSetExternalCreateFailed(t *testing.T) {
now := time.Now()
cases := map[string]struct {
o metav1.Object
t time.Time
want metav1.Object
}{
"SetsTheCorrectKey": {
o: &corev1.Pod{},
t: now,
want: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationKeyExternalCreateFailed: now.Format(time.RFC3339)}}},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
SetExternalCreateFailed(tc.o, tc.t)
if diff := cmp.Diff(tc.want, tc.o); diff != "" {
t.Errorf("SetExternalCreateFailed(...): -want, +got:\n%s", diff)
}
})
}
}
func TestExternalCreateSucceededDuring(t *testing.T) {
type args struct {
o metav1.Object
d time.Duration
}
cases := map[string]struct {
args args
want bool
}{
"NotYetSuccessfullyCreated": {
args: args{
o: &corev1.Pod{},
d: 1 * time.Minute,
},
want: false,
},
"SuccessfullyCreatedTooLongAgo": {
args: args{
o: func() metav1.Object {
o := &corev1.Pod{}
t := time.Now().Add(-2 * time.Minute)
SetExternalCreateSucceeded(o, t)
return o
}(),
d: 1 * time.Minute,
},
want: false,
},
"SuccessfullyCreatedWithinDuration": {
args: args{
o: func() metav1.Object {
o := &corev1.Pod{}
t := time.Now().Add(-30 * time.Second)
SetExternalCreateSucceeded(o, t)
return o
}(),
d: 1 * time.Minute,
},
want: true,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := ExternalCreateSucceededDuring(tc.args.o, tc.args.d)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("ExternalCreateSucceededDuring(...): -want, +got:\n%s", diff)
}
})
}
}
func TestExternalCreateIncomplete(t *testing.T) {
now := time.Now().Format(time.RFC3339)
earlier := time.Now().Add(-1 * time.Second).Format(time.RFC3339)
evenEarlier := time.Now().Add(-1 * time.Minute).Format(time.RFC3339)
cases := map[string]struct {
reason string
o metav1.Object
want bool
}{
"CreateNeverPending": {
reason: "If we've never called Create it can't be incomplete.",
o: &corev1.Pod{},
want: false,
},
"CreateSucceeded": {
reason: "If Create succeeded since it was pending, it's complete.",
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
AnnotationKeyExternalCreateFailed: evenEarlier,
AnnotationKeyExternalCreatePending: earlier,
AnnotationKeyExternalCreateSucceeded: now,
}}},
want: false,
},
"CreateFailed": {
reason: "If Create failed since it was pending, it's complete.",
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
AnnotationKeyExternalCreateSucceeded: evenEarlier,
AnnotationKeyExternalCreatePending: earlier,
AnnotationKeyExternalCreateFailed: now,
}}},
want: false,
},
"CreateNeverCompleted": {
reason: "If Create was pending but never succeeded or failed, it's incomplete.",
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
AnnotationKeyExternalCreatePending: earlier,
}}},
want: true,
},
"RecreateNeverCompleted": {
reason: "If Create is pending and there's an older success we're probably trying to recreate a deleted external resource, and it's incomplete.",
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
AnnotationKeyExternalCreateSucceeded: earlier,
AnnotationKeyExternalCreatePending: now,
}}},
want: true,
},
"RetryNeverCompleted": {
reason: "If Create is pending and there's an older failure we're probably trying to recreate a deleted external resource, and it's incomplete.",
o: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
AnnotationKeyExternalCreateFailed: earlier,
AnnotationKeyExternalCreatePending: now,
}}},
want: true,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := ExternalCreateIncomplete(tc.o)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("ExternalCreateIncomplete(...): -want, +got:\n%s", diff)
}
})
}
}
func TestAllowPropagation(t *testing.T) { func TestAllowPropagation(t *testing.T) {
fromns := "from-namespace" fromns := "from-namespace"
from := "from-name" from := "from-name"

View File

@ -22,6 +22,8 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/pkg/errors" "github.com/pkg/errors"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
@ -35,6 +37,7 @@ const (
errUpdateManaged = "cannot update managed resource" errUpdateManaged = "cannot update managed resource"
errUpdateManagedStatus = "cannot update managed resource status" errUpdateManagedStatus = "cannot update managed resource status"
errResolveReferences = "cannot resolve references" errResolveReferences = "cannot resolve references"
errUpdateCriticalAnnotations = "cannot update critical annotations"
) )
// NameAsExternalName writes the name of the managed resource to // NameAsExternalName writes the name of the managed resource to
@ -152,3 +155,33 @@ func (a *APISimpleReferenceResolver) ResolveReferences(ctx context.Context, mg r
return errors.Wrap(a.client.Update(ctx, mg), errUpdateManaged) return errors.Wrap(a.client.Update(ctx, mg), errUpdateManaged)
} }
// A RetryingCriticalAnnotationUpdater is a CriticalAnnotationUpdater that
// retries annotation updates in the face of API server errors.
type RetryingCriticalAnnotationUpdater struct {
client client.Client
}
// NewRetryingCriticalAnnotationUpdater returns a CriticalAnnotationUpdater that
// retries annotation updates in the face of API server errors.
func NewRetryingCriticalAnnotationUpdater(c client.Client) *RetryingCriticalAnnotationUpdater {
return &RetryingCriticalAnnotationUpdater{client: c}
}
// 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. Any
// pending changes to the supplied Object's spec, status, or other metadata are
// reset to their current state according to the API server.
func (u *RetryingCriticalAnnotationUpdater) UpdateCriticalAnnotations(ctx context.Context, o client.Object) error {
a := o.GetAnnotations()
err := retry.OnError(retry.DefaultRetry, resource.IsAPIError, func() error {
nn := types.NamespacedName{Name: o.GetName()}
if err := u.client.Get(ctx, nn, o); err != nil {
return err
}
meta.AddAnnotations(o, a)
return u.client.Update(ctx, o)
})
return errors.Wrap(err, errUpdateCriticalAnnotations)
}

View File

@ -377,7 +377,67 @@ func TestResolveReferences(t *testing.T) {
r := NewAPISimpleReferenceResolver(tc.c) r := NewAPISimpleReferenceResolver(tc.c)
got := r.ResolveReferences(tc.args.ctx, tc.args.mg) got := r.ResolveReferences(tc.args.ctx, tc.args.mg)
if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" { if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" {
t.Errorf("\nReason: %s\r.ResolveReferences(...): -want, +got:\n%s", tc.reason, diff) t.Errorf("\n%s\nr.ResolveReferences(...): -want, +got:\n%s", tc.reason, diff)
}
})
}
}
func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
errBoom := errors.New("boom")
type args struct {
ctx context.Context
o client.Object
}
cases := map[string]struct {
reason string
c client.Client
args args
want error
}{
"GetError": {
reason: "We should return any error we encounter getting the supplied object",
c: &test.MockClient{
MockGet: test.NewMockGetFn(errBoom),
},
args: args{
o: &fake.Managed{},
},
want: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
},
"UpdateError": {
reason: "We should return any error we encounter updating the supplied object",
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(errBoom),
},
args: args{
o: &fake.Managed{},
},
want: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
},
"Success": {
reason: "We should return without error if we successfully update our annotations",
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(errBoom),
},
args: args{
o: &fake.Managed{},
},
want: errors.Wrap(errBoom, errUpdateCriticalAnnotations),
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
u := NewRetryingCriticalAnnotationUpdater(tc.c)
got := u.UpdateCriticalAnnotations(tc.args.ctx, tc.args.o)
if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nu.UpdateCriticalAnnotations(...): -want, +got:\n%s", tc.reason, diff)
} }
}) })
} }

View File

@ -21,9 +21,6 @@ import (
"strings" "strings"
"time" "time"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"github.com/pkg/errors" "github.com/pkg/errors"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@ -43,12 +40,14 @@ const (
reconcileTimeout = 1 * time.Minute reconcileTimeout = 1 * time.Minute
defaultpollInterval = 1 * time.Minute defaultpollInterval = 1 * time.Minute
defaultGracePeriod = 30 * time.Second
) )
// Error strings. // Error strings.
const ( const (
errGetManaged = "cannot get managed resource" errGetManaged = "cannot get managed resource"
errUpdateManagedAfterCreate = "cannot update managed resource. this may have resulted in a leaked external resource" errUpdateManagedAnnotations = "cannot update managed resource annotations"
errCreateIncomplete = "cannot determine creation result - remove the " + meta.AnnotationKeyExternalCreatePending + " annotation if it is safe to proceed"
errReconcileConnect = "connect failed" errReconcileConnect = "connect failed"
errReconcileObserve = "observe failed" errReconcileObserve = "observe failed"
errReconcileCreate = "create failed" errReconcileCreate = "create failed"
@ -72,6 +71,7 @@ const (
reasonDeleted event.Reason = "DeletedExternalResource" reasonDeleted event.Reason = "DeletedExternalResource"
reasonCreated event.Reason = "CreatedExternalResource" reasonCreated event.Reason = "CreatedExternalResource"
reasonUpdated event.Reason = "UpdatedExternalResource" reasonUpdated event.Reason = "UpdatedExternalResource"
reasonPending event.Reason = "PendingExternalResource"
) )
// ControllerName returns the recommended name for controllers that use this // ControllerName returns the recommended name for controllers that use this
@ -80,6 +80,21 @@ func ControllerName(kind string) string {
return "managed/" + strings.ToLower(kind) return "managed/" + strings.ToLower(kind)
} }
// A CriticalAnnotationUpdater is used when it is critical that annotations must
// be updated before returning from the Reconcile loop.
type CriticalAnnotationUpdater interface {
UpdateCriticalAnnotations(ctx context.Context, o client.Object) error
}
// A CriticalAnnotationUpdateFn may be used when it is critical that annotations
// must be updated before returning from the Reconcile loop.
type CriticalAnnotationUpdateFn func(ctx context.Context, o client.Object) error
// UpdateCriticalAnnotations of the supplied object.
func (fn CriticalAnnotationUpdateFn) UpdateCriticalAnnotations(ctx context.Context, o client.Object) error {
return fn(ctx, o)
}
// ConnectionDetails created or updated during an operation on an external // ConnectionDetails created or updated during an operation on an external
// resource, for example usernames, passwords, endpoints, ports, etc. // resource, for example usernames, passwords, endpoints, ports, etc.
type ConnectionDetails map[string][]byte type ConnectionDetails map[string][]byte
@ -185,15 +200,19 @@ func (ec ExternalConnectorFn) Connect(ctx context.Context, mg resource.Managed)
// if it's called again with the same parameters or Delete call should not // if it's called again with the same parameters or Delete call should not
// return error if there is an ongoing deletion or resource does not exist. // return error if there is an ongoing deletion or resource does not exist.
type ExternalClient interface { type ExternalClient interface {
// Observe the external resource the supplied Managed resource represents, // Observe the external resource the supplied Managed resource
// if any. Observe implementations must not modify the external resource, // represents, if any. Observe implementations must not modify the
// but may update the supplied Managed resource to reflect the state of the // external resource, but may update the supplied Managed resource to
// external resource. // reflect the state of the external resource. Status modifications are
// automatically persisted unless ResourceLateInitialized is true - see
// ResourceLateInitialized for more detail.
Observe(ctx context.Context, mg resource.Managed) (ExternalObservation, error) Observe(ctx context.Context, mg resource.Managed) (ExternalObservation, error)
// Create an external resource per the specifications of the supplied // Create an external resource per the specifications of the supplied
// Managed resource. Called when Observe reports that the associated // Managed resource. Called when Observe reports that the associated
// external resource does not exist. // external resource does not exist. Create implementations may update
// managed resource annotations, and those updates will be persisted.
// All other updates will be discarded.
Create(ctx context.Context, mg resource.Managed) (ExternalCreation, error) Create(ctx context.Context, mg resource.Managed) (ExternalCreation, error)
// Update the external resource represented by the supplied Managed // Update the external resource represented by the supplied Managed
@ -316,10 +335,14 @@ type ExternalObservation struct {
// An ExternalCreation is the result of the creation of an external resource. // An ExternalCreation is the result of the creation of an external resource.
type ExternalCreation struct { type ExternalCreation struct {
// ExternalNameAssigned is true if the Create operation resulted in a change // ExternalNameAssigned should be true if the Create operation resulted
// in the external name annotation. If that's the case, we need to issue a // in a change in the resource's external name. This is typically only
// spec update and make sure it goes through so that we don't lose the identifier // needed for external resource's with unpredictable external names that
// of the resource we just created. // are returned from the API at create time.
//
// Deprecated: The managed.Reconciler no longer needs to be informed
// when an external name is assigned by the Create operation. It will
// automatically detect and handle external name assignment.
ExternalNameAssigned bool ExternalNameAssigned bool
// ConnectionDetails required to connect to this resource. These details // ConnectionDetails required to connect to this resource. These details
@ -350,6 +373,7 @@ type Reconciler struct {
pollInterval time.Duration pollInterval time.Duration
timeout time.Duration timeout time.Duration
creationGracePeriod time.Duration
// The below structs embed the set of interfaces used to implement the // The below structs embed the set of interfaces used to implement the
// managed resource reconciler. We do this primarily for readability, so // managed resource reconciler. We do this primarily for readability, so
@ -363,6 +387,7 @@ type Reconciler struct {
} }
type mrManaged struct { type mrManaged struct {
CriticalAnnotationUpdater
ConnectionPublisher ConnectionPublisher
resource.Finalizer resource.Finalizer
Initializer Initializer
@ -371,6 +396,7 @@ type mrManaged struct {
func defaultMRManaged(m manager.Manager) mrManaged { func defaultMRManaged(m manager.Manager) mrManaged {
return mrManaged{ return mrManaged{
CriticalAnnotationUpdater: NewRetryingCriticalAnnotationUpdater(m.GetClient()),
ConnectionPublisher: NewAPISecretPublisher(m.GetClient(), m.GetScheme()), ConnectionPublisher: NewAPISecretPublisher(m.GetClient(), m.GetScheme()),
Finalizer: resource.NewAPIFinalizer(m.GetClient(), managedFinalizerName), Finalizer: resource.NewAPIFinalizer(m.GetClient(), managedFinalizerName),
Initializer: NewNameAsExternalName(m.GetClient()), Initializer: NewNameAsExternalName(m.GetClient()),
@ -412,6 +438,17 @@ func WithPollInterval(after time.Duration) ReconcilerOption {
} }
} }
// WithCreationGracePeriod configures an optional period during which we will
// wait for the external API to report that a newly created external resource
// exists. This allows us to tolerate eventually consistent APIs that do not
// immediately report that newly created resources exist when queried. All
// resources have a 30 second grace period by default.
func WithCreationGracePeriod(d time.Duration) ReconcilerOption {
return func(r *Reconciler) {
r.creationGracePeriod = d
}
}
// WithExternalConnecter specifies how the Reconciler should connect to the API // WithExternalConnecter specifies how the Reconciler should connect to the API
// used to sync and delete external resources. // used to sync and delete external resources.
func WithExternalConnecter(c ExternalConnecter) ReconcilerOption { func WithExternalConnecter(c ExternalConnecter) ReconcilerOption {
@ -420,6 +457,16 @@ func WithExternalConnecter(c ExternalConnecter) ReconcilerOption {
} }
} }
// WithCriticalAnnotationUpdater specifies how the Reconciler should update a
// managed resource's critical annotations. Implementations typically contain
// some kind of retry logic to increase the likelihood that critical annotations
// (like non-deterministic external names) will be persisted.
func WithCriticalAnnotationUpdater(u CriticalAnnotationUpdater) ReconcilerOption {
return func(r *Reconciler) {
r.managed.CriticalAnnotationUpdater = u
}
}
// WithConnectionPublishers specifies how the Reconciler should publish // WithConnectionPublishers specifies how the Reconciler should publish
// its connection details such as credentials and endpoints. // its connection details such as credentials and endpoints.
func WithConnectionPublishers(p ...ConnectionPublisher) ReconcilerOption { func WithConnectionPublishers(p ...ConnectionPublisher) ReconcilerOption {
@ -486,6 +533,7 @@ func NewReconciler(m manager.Manager, of resource.ManagedKind, o ...ReconcilerOp
client: m.GetClient(), client: m.GetClient(),
newManaged: nm, newManaged: nm,
pollInterval: defaultpollInterval, pollInterval: defaultpollInterval,
creationGracePeriod: defaultGracePeriod,
timeout: reconcileTimeout, timeout: reconcileTimeout,
managed: defaultMRManaged(m), managed: defaultMRManaged(m),
external: defaultMRExternal(), external: defaultMRExternal(),
@ -581,6 +629,17 @@ func (r *Reconciler) Reconcile(_ context.Context, req reconcile.Request) (reconc
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus) return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
} }
// If we started but never completed creation of an external resource we
// may have lost critical information. For example if we didn't persist
// an updated external name we've leaked a resource. The safest thing to
// do is to refuse to proceed.
if meta.ExternalCreateIncomplete(managed) {
log.Debug(errCreateIncomplete)
record.Event(managed, event.Warning(reasonCannotInitialize, errors.New(errCreateIncomplete)))
managed.SetConditions(xpv1.ReconcileError(errors.New(errCreateIncomplete)))
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
}
// We resolve any references before observing our external resource because // We resolve any references before observing our external resource because
// in some rare examples we need a spec field to make the observe call, and // in some rare examples we need a spec field to make the observe call, and
// that spec field could be set by a reference. // that spec field could be set by a reference.
@ -631,6 +690,17 @@ func (r *Reconciler) Reconcile(_ context.Context, req reconcile.Request) (reconc
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus) return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
} }
// If this resource has a non-zero creation grace period we want to wait
// for that period to expire before we trust that the resource really
// doesn't exist. This is because some external APIs are eventually
// consistent and may report that a recently created resource does not
// exist.
if !observation.ResourceExists && meta.ExternalCreateSucceededDuring(managed, r.creationGracePeriod) {
log.Debug("Waiting for external resource existence to be confirmed")
record.Event(managed, event.Normal(reasonPending, "Waiting for external resource existence to be confirmed"))
return reconcile.Result{Requeue: true}, nil
}
if meta.WasDeleted(managed) { if meta.WasDeleted(managed) {
log = log.WithValues("deletion-timestamp", managed.GetDeletionTimestamp()) log = log.WithValues("deletion-timestamp", managed.GetDeletionTimestamp())
managed.SetConditions(xpv1.Deleting()) managed.SetConditions(xpv1.Deleting())
@ -711,6 +781,21 @@ func (r *Reconciler) Reconcile(_ context.Context, req reconcile.Request) (reconc
} }
if !observation.ResourceExists { if !observation.ResourceExists {
// We write this annotation for two reasons. Firstly, it helps
// us to detect the case in which we fail to persist critical
// information (like the external name) that may be set by the
// subsequent external.Create call. Secondly, it guarantees that
// we're operating on the latest version of our resource. We
// don't use the CriticalAnnotationUpdater because we _want_ the
// update to fail if we get a 409 due to a stale version.
meta.SetExternalCreatePending(managed, time.Now())
if err := r.client.Update(ctx, managed); err != nil {
log.Debug(errUpdateManaged, "error", err)
record.Event(managed, event.Warning(reasonCannotUpdateManaged, errors.Wrap(err, errUpdateManaged)))
managed.SetConditions(xpv1.ReconcileError(errors.Wrap(err, errUpdateManaged)))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
}
managed.SetConditions(xpv1.Creating()) managed.SetConditions(xpv1.Creating())
creation, err := external.Create(externalCtx, managed) creation, err := external.Create(externalCtx, managed)
if err != nil { if err != nil {
@ -721,31 +806,50 @@ func (r *Reconciler) Reconcile(_ context.Context, req reconcile.Request) (reconc
// the new error condition. If not, we requeue explicitly, which will trigger backoff. // the new error condition. If not, we requeue explicitly, which will trigger backoff.
log.Debug("Cannot create external resource", "error", err) log.Debug("Cannot create external resource", "error", err)
record.Event(managed, event.Warning(reasonCannotCreate, err)) record.Event(managed, event.Warning(reasonCannotCreate, err))
// We handle annotations specially here because it's
// critical that they are persisted to the API server.
// If we don't add the external-create-failed annotation
// the reconciler will refuse to proceed, because it
// won't know whether or not it created an external
// resource.
meta.SetExternalCreateFailed(managed, time.Now())
if err := r.managed.UpdateCriticalAnnotations(ctx, managed); err != nil {
log.Debug(errUpdateManagedAnnotations, "error", err)
record.Event(managed, event.Warning(reasonCannotUpdateManaged, errors.Wrap(err, errUpdateManagedAnnotations)))
// We only log and emit an event here rather
// than setting a status condition and returning
// early because presumably it's more useful to
// set our status condition to the reason the
// create failed.
}
managed.SetConditions(xpv1.ReconcileError(errors.Wrap(err, errReconcileCreate))) managed.SetConditions(xpv1.ReconcileError(errors.Wrap(err, errReconcileCreate)))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus) return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
} }
if creation.ExternalNameAssigned { // In some cases our external-name may be set by Create above.
en := meta.GetExternalName(managed) log = log.WithValues("external-name", meta.GetExternalName(managed))
// We will retry in all cases where the error comes from the api-server. record = r.record.WithAnnotations("external-name", meta.GetExternalName(managed))
// At one point, context deadline will be exceeded and we'll get out
// of the loop. In that case, we warn the user that the external resource // We handle annotations specially here because it's critical
// might be leaked. // that they are persisted to the API server. If we don't remove
err := retry.OnError(retry.DefaultRetry, resource.IsAPIError, func() error { // add the external-create-succeeded annotation the reconciler
nn := types.NamespacedName{Name: managed.GetName()} // will refuse to proceed, because it won't know whether or not
if err := r.client.Get(ctx, nn, managed); err != nil { // it created an external resource. This is also important in
return err // cases where we must record an external-name annotation set by
} // the Create call. Any other changes made during Create will be
meta.SetExternalName(managed, en) // reverted when annotations are updated; at the time of writing
return r.client.Update(ctx, managed) // Create implementations are advised not to alter status, but
}) // we may revisit this in future.
if err != nil { meta.SetExternalCreateSucceeded(managed, time.Now())
log.Debug("Cannot update managed resource", "error", err) if err := r.managed.UpdateCriticalAnnotations(ctx, managed); err != nil {
record.Event(managed, event.Warning(reasonCannotUpdateManaged, errors.Wrap(err, errUpdateManagedAfterCreate))) log.Debug(errUpdateManagedAnnotations, "error", err)
managed.SetConditions(xpv1.ReconcileError(errors.Wrap(err, errUpdateManagedAfterCreate))) record.Event(managed, event.Warning(reasonCannotUpdateManaged, errors.Wrap(err, errUpdateManagedAnnotations)))
managed.SetConditions(xpv1.ReconcileError(errors.Wrap(err, errUpdateManagedAnnotations)))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus) return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, managed), errUpdateManagedStatus)
} }
}
if err := r.managed.PublishConnection(ctx, managed, creation.ConnectionDetails); err != nil { if err := r.managed.PublishConnection(ctx, managed, creation.ConnectionDetails); err != nil {
// If this is the first time we encounter this issue we'll be // If this is the first time we encounter this issue we'll be

View File

@ -19,12 +19,14 @@ package managed
import ( import (
"context" "context"
"testing" "testing"
"time"
kerrors "k8s.io/apimachinery/pkg/api/errors" kerrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/pkg/errors" "github.com/pkg/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/manager"
@ -199,6 +201,35 @@ func TestReconciler(t *testing.T) {
}, },
want: want{result: reconcile.Result{Requeue: true}}, want: want{result: reconcile.Result{Requeue: true}},
}, },
"ExternalCreatePending": {
reason: "We should return early if the managed resource appears to be pending creation. We might have leaked a resource and don't want to create another.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
meta.SetExternalCreatePending(obj, now.Time)
return nil
}),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalCreatePending(want, now.Time)
want.SetConditions(xpv1.ReconcileError(errors.New(errCreateIncomplete)))
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "We should update our status when we're asked to reconcile a managed resource that is pending creation."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(InitializerFn(func(_ context.Context, mg resource.Managed) error { return nil })),
},
},
want: want{result: reconcile.Result{Requeue: false}},
},
"ResolveReferencesError": { "ResolveReferencesError": {
reason: "Errors during reference resolution references should trigger a requeue after a short wait.", reason: "Errors during reference resolution references should trigger a requeue after a short wait.",
args: args{ args: args{
@ -288,6 +319,34 @@ func TestReconciler(t *testing.T) {
}, },
want: want{result: reconcile.Result{Requeue: true}}, want: want{result: reconcile.Result{Requeue: true}},
}, },
"CreationGracePeriod": {
reason: "If our resource appears not to exist during the creation grace period we should return early.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
meta.SetExternalCreateSucceeded(obj, time.Now())
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithCreationGracePeriod(1 * time.Minute),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: false}, nil
},
}
return c, nil
})),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"ExternalDeleteError": { "ExternalDeleteError": {
reason: "Errors deleting the external resource should trigger a requeue after a short wait.", reason: "Errors deleting the external resource should trigger a requeue after a short wait.",
args: args{ args: args{
@ -558,17 +617,18 @@ func TestReconciler(t *testing.T) {
}, },
want: want{result: reconcile.Result{Requeue: true}}, want: want{result: reconcile.Result{Requeue: true}},
}, },
"CreateExternalError": { "UpdateCreatePendingError": {
reason: "Errors while creating an external resource should trigger a requeue after a short wait.", reason: "Errors while updating our external-create-pending annotation should trigger a requeue after a short wait.",
args: args{ args: args{
m: &fake.Manager{ m: &fake.Manager{
Client: &test.MockClient{ Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil), MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{} want := &fake.Managed{}
want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errReconcileCreate))) meta.SetExternalCreatePending(want, time.Now())
want.SetConditions(xpv1.Creating()) want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errUpdateManaged)))
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" { if diff := cmp.Diff(want, obj, test.EquateConditions(), cmpopts.EquateApproxTime(1*time.Second)); diff != "" {
reason := "Errors while creating an external resource should be reported as a conditioned status." reason := "Errors while creating an external resource should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff) t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
} }
@ -598,17 +658,109 @@ func TestReconciler(t *testing.T) {
}, },
want: want{result: reconcile.Result{Requeue: true}}, want: want{result: reconcile.Result{Requeue: true}},
}, },
"CreateExternalError": {
reason: "Errors while creating an external resource should trigger a requeue after a short wait.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(nil),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalCreatePending(want, time.Now())
meta.SetExternalCreateFailed(want, time.Now())
want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errReconcileCreate)))
want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions(), cmpopts.EquateApproxTime(1*time.Second)); diff != "" {
reason := "Errors while creating an external resource should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: false}, nil
},
CreateFn: func(_ context.Context, _ resource.Managed) (ExternalCreation, error) {
return ExternalCreation{}, errBoom
},
}
return c, nil
})),
// We simulate our critical annotation update failing too here.
// This is mostly just to exercise the code, which just creates a log and an event.
WithCriticalAnnotationUpdater(CriticalAnnotationUpdateFn(func(ctx context.Context, o client.Object) error { return errBoom })),
WithConnectionPublishers(),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"UpdateCriticalAnnotationsError": {
reason: "Errors updating critical annotations after creation should trigger a requeue after a short wait.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(nil),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalCreatePending(want, time.Now())
meta.SetExternalCreateSucceeded(want, time.Now())
want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errUpdateManagedAnnotations)))
want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions(), cmpopts.EquateApproxTime(1*time.Second)); diff != "" {
reason := "Errors updating critical annotations after creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{ResourceExists: false}, nil
},
CreateFn: func(_ context.Context, _ resource.Managed) (ExternalCreation, error) {
return ExternalCreation{}, nil
},
}
return c, nil
})),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
WithCriticalAnnotationUpdater(CriticalAnnotationUpdateFn(func(ctx context.Context, o client.Object) error { return errBoom })),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"PublishCreationConnectionDetailsError": { "PublishCreationConnectionDetailsError": {
reason: "Errors publishing connection details after creation should trigger a requeue after a short wait.", reason: "Errors publishing connection details after creation should trigger a requeue after a short wait.",
args: args{ args: args{
m: &fake.Manager{ m: &fake.Manager{
Client: &test.MockClient{ Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil), MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(nil),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{} want := &fake.Managed{}
meta.SetExternalCreatePending(want, time.Now())
meta.SetExternalCreateSucceeded(want, time.Now())
want.SetConditions(xpv1.ReconcileError(errBoom)) want.SetConditions(xpv1.ReconcileError(errBoom))
want.SetConditions(xpv1.Creating()) want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" { if diff := cmp.Diff(want, obj, test.EquateConditions(), cmpopts.EquateApproxTime(1*time.Second)); diff != "" {
reason := "Errors publishing connection details after creation should be reported as a conditioned status." reason := "Errors publishing connection details after creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff) t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
} }
@ -633,6 +785,7 @@ func TestReconciler(t *testing.T) {
} }
return c, nil return c, nil
})), })),
WithCriticalAnnotationUpdater(CriticalAnnotationUpdateFn(func(ctx context.Context, o client.Object) error { return nil })),
WithConnectionPublishers(ConnectionPublisherFns{ WithConnectionPublishers(ConnectionPublisherFns{
PublishConnectionFn: func(_ context.Context, _ resource.Managed, cd ConnectionDetails) error { PublishConnectionFn: func(_ context.Context, _ resource.Managed, cd ConnectionDetails) error {
// We're called after observe, create, and update // We're called after observe, create, and update
@ -655,11 +808,14 @@ func TestReconciler(t *testing.T) {
m: &fake.Manager{ m: &fake.Manager{
Client: &test.MockClient{ Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil), MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(nil),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{} want := &fake.Managed{}
meta.SetExternalCreatePending(want, time.Now())
meta.SetExternalCreateSucceeded(want, time.Now())
want.SetConditions(xpv1.ReconcileSuccess()) want.SetConditions(xpv1.ReconcileSuccess())
want.SetConditions(xpv1.Creating()) want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" { if diff := cmp.Diff(want, obj, test.EquateConditions(), cmpopts.EquateApproxTime(1*time.Second)); diff != "" {
reason := "Successful managed resource creation should be reported as a conditioned status." reason := "Successful managed resource creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff) t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
} }
@ -673,139 +829,7 @@ func TestReconciler(t *testing.T) {
WithInitializers(), WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })), WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(&NopConnecter{}), WithExternalConnecter(&NopConnecter{}),
WithConnectionPublishers(), WithCriticalAnnotationUpdater(CriticalAnnotationUpdateFn(func(ctx context.Context, o client.Object) error { return nil })),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"CreateWithExternalNameAssignmentSuccessful": {
reason: "Successful managed resource creation with external name assignment should trigger an update.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(nil),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalName(want, "test")
want.SetConditions(xpv1.ReconcileSuccess())
want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "Successful managed resource creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
CreateFn: func(_ context.Context, mg resource.Managed) (ExternalCreation, error) {
meta.SetExternalName(mg, "test")
return ExternalCreation{ExternalNameAssigned: true}, nil
},
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{}, nil
},
}
return c, nil
})),
WithConnectionPublishers(),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"CreateWithExternalNameAssignmentGetError": {
reason: "If the Get call during the update after Create does not go through, we need to inform the user and requeue shortly.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: func(_ context.Context, _ client.ObjectKey, obj client.Object) error {
if meta.GetExternalName(obj.(metav1.Object)) == "test" {
return errBoom
}
return nil
},
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalName(want, "test")
want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errUpdateManagedAfterCreate)))
want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "Successful managed resource creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
CreateFn: func(_ context.Context, mg resource.Managed) (ExternalCreation, error) {
meta.SetExternalName(mg, "test")
return ExternalCreation{ExternalNameAssigned: true}, nil
},
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{}, nil
},
}
return c, nil
})),
WithConnectionPublishers(),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
},
},
want: want{result: reconcile.Result{Requeue: true}},
},
"CreateWithExternalNameAssignmentUpdateError": {
reason: "If the update after Create does not go through, we need to inform the user and requeue shortly.",
args: args{
m: &fake.Manager{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockUpdate: test.NewMockUpdateFn(errBoom),
MockStatusUpdate: test.MockStatusUpdateFn(func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
want := &fake.Managed{}
meta.SetExternalName(want, "test")
want.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errUpdateManagedAfterCreate)))
want.SetConditions(xpv1.Creating())
if diff := cmp.Diff(want, obj, test.EquateConditions()); diff != "" {
reason := "Successful managed resource creation should be reported as a conditioned status."
t.Errorf("\nReason: %s\n-want, +got:\n%s", reason, diff)
}
return nil
}),
},
Scheme: fake.SchemeWith(&fake.Managed{}),
},
mg: resource.ManagedKind(fake.GVK(&fake.Managed{})),
o: []ReconcilerOption{
WithInitializers(),
WithReferenceResolver(ReferenceResolverFn(func(_ context.Context, _ resource.Managed) error { return nil })),
WithExternalConnecter(ExternalConnectorFn(func(_ context.Context, mg resource.Managed) (ExternalClient, error) {
c := &ExternalClientFns{
CreateFn: func(_ context.Context, mg resource.Managed) (ExternalCreation, error) {
meta.SetExternalName(mg, "test")
return ExternalCreation{ExternalNameAssigned: true}, nil
},
ObserveFn: func(_ context.Context, _ resource.Managed) (ExternalObservation, error) {
return ExternalObservation{}, nil
},
}
return c, nil
})),
WithConnectionPublishers(), WithConnectionPublishers(),
WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}), WithFinalizer(resource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ resource.Object) error { return nil }}),
}, },