Add an Apply object function

This function either creates a new object or patches an existing one. I think
it belongs in crossplane-runtime because we have at least two implementations in
the wild, and I need another one for a new controller.

https://github.com/crossplane/templating-controller/blob/a035e2/pkg/controllers/templating_reconciler.go#L168
https://github.com/crossplane/crossplane/blob/c1933feab/pkg/controller/workload/kubernetes/resource/resource.go#L260

Signed-off-by: Nic Cope <negz@rk0n.org>
This commit is contained in:
Nic Cope 2020-02-26 22:53:51 -08:00
parent ca61a42964
commit 6ef3f96332
2 changed files with 177 additions and 0 deletions

View File

@ -18,6 +18,7 @@ package resource
import (
"context"
"encoding/json"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
@ -25,6 +26,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/crossplane/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplane/crossplane-runtime/pkg/meta"
@ -227,3 +230,57 @@ func IsBound(b Bindable) bool {
func IsConditionTrue(c v1alpha1.Condition) bool {
return c.Status == corev1.ConditionTrue
}
// ApplyOptions configure how changes are applied to an object.
type ApplyOptions struct {
// ControllersMustMatch requires any existing object to have a controller
// reference, and for that controller reference to match the controller
// reference of the supplied object.
ControllersMustMatch bool
}
// An ApplyOption configures how changes are applied to an object.
type ApplyOption func(a *ApplyOptions)
// ControllersMustMatch requires any existing object to have a controller
// reference, and for that controller reference to match the controller
// reference of the supplied object.
func ControllersMustMatch() ApplyOption {
return func(a *ApplyOptions) {
a.ControllersMustMatch = true
}
}
// Apply changes to the supplied object. The object will be created if it does
// not exist, or patched if it does.
func Apply(ctx context.Context, c client.Client, o runtime.Object, ao ...ApplyOption) error {
opts := &ApplyOptions{}
for _, fn := range ao {
fn(opts)
}
existing := o.DeepCopyObject()
m, ok := existing.(metav1.Object)
if !ok {
return errors.New("cannot access object metadata")
}
err := c.Get(ctx, types.NamespacedName{Name: m.GetName(), Namespace: m.GetNamespace()}, existing)
if kerrors.IsNotFound(err) {
return errors.Wrap(c.Create(ctx, o), "cannot create object")
}
if err != nil {
return errors.Wrap(err, "cannot get object")
}
if opts.ControllersMustMatch && !meta.HaveSameController(o.(metav1.Object), m) {
return errors.New("existing object has a different (or no) controller")
}
return errors.Wrap(c.Patch(ctx, existing, &patch{o}), "cannot patch object")
}
type patch struct{ from runtime.Object }
func (p *patch) Type() types.PatchType { return types.MergePatchType }
func (p *patch) Data(_ runtime.Object) ([]byte, error) { return json.Marshal(p.from) }

View File

@ -17,17 +17,21 @@ limitations under the License.
package resource
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/crossplane/crossplane-runtime/apis/core/v1alpha1"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource/fake"
"github.com/crossplane/crossplane-runtime/pkg/test"
)
@ -440,3 +444,119 @@ func TestIsConditionTrue(t *testing.T) {
})
}
}
type object struct {
runtime.Object
metav1.ObjectMeta
}
func (o *object) DeepCopyObject() runtime.Object {
return &object{ObjectMeta: *o.ObjectMeta.DeepCopy()}
}
type nopeject struct {
runtime.Object
}
func (o *nopeject) DeepCopyObject() runtime.Object {
return &nopeject{}
}
func TestApply(t *testing.T) {
errBoom := errors.New("boom")
type args struct {
ctx context.Context
c client.Client
o runtime.Object
ao []ApplyOption
}
cases := map[string]struct {
reason string
args args
want error
}{
"NotAMetadataObject": {
reason: "An error should be returned if we can't access the object's metadata",
args: args{
c: &test.MockClient{MockGet: test.NewMockGetFn(errBoom)},
o: &nopeject{},
},
want: errors.New("cannot access object metadata"),
},
"GetError": {
reason: "An error should be returned if we can't get the object",
args: args{
c: &test.MockClient{MockGet: test.NewMockGetFn(errBoom)},
o: &object{},
},
want: errors.Wrap(errBoom, "cannot get object"),
},
"CreateError": {
reason: "No error should be returned if we successfully create a new object",
args: args{
c: &test.MockClient{
MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")),
MockCreate: test.NewMockCreateFn(errBoom),
},
o: &object{},
},
want: errors.Wrap(errBoom, "cannot create object"),
},
"ControllerMismatch": {
reason: "An error should be returned if controllers must match, but don't",
args: args{
c: &test.MockClient{MockGet: test.NewMockGetFn(nil, func(o runtime.Object) error {
obj := &object{}
meta.AddControllerReference(obj, metav1.OwnerReference{UID: types.UID("wat")})
*(o.(*object)) = *obj
return nil
})},
o: &object{},
ao: []ApplyOption{ControllersMustMatch()},
},
want: errors.New("existing object has a different (or no) controller"),
},
"PatchError": {
reason: "An error should be returned if we can't patch the object",
args: args{
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockPatch: test.NewMockPatchFn(errBoom),
},
o: &object{},
},
want: errors.Wrap(errBoom, "cannot patch object"),
},
"Created": {
reason: "No error should be returned if we successfully create a new object",
args: args{
c: &test.MockClient{
MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")),
MockCreate: test.NewMockCreateFn(nil),
},
o: &object{},
},
},
"Patched": {
reason: "No error should be returned if we successfully patch an existing object",
args: args{
c: &test.MockClient{
MockGet: test.NewMockGetFn(nil),
MockPatch: test.NewMockPatchFn(nil),
},
o: &object{},
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
err := Apply(tc.args.ctx, tc.args.c, tc.args.o, tc.args.ao...)
if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nApply(...): -want error, +got error\n%s\n", tc.reason, diff)
}
})
}
}