Have the claim controller support both CSA and SSA

This reintroduces the old client-side apply claim logic. Instead of two
'Configurators' (one for the XR and one for the claim) it merges the
logic into one 'Syncer'.

It keeps the new server-side apply claim logic, but also merges it from
two 'Configurators' into one 'Syncer'.

This allows us to toggle server-side apply on and off by switching which
Syncer the controller uses, allowing SSA to be put behind a feature
flag.

Signed-off-by: Nic Cope <nicc@rk0n.org>
This commit is contained in:
Nic Cope 2024-01-30 22:34:38 -08:00
parent 6d4f6d92c2
commit 5b3650ae8b
13 changed files with 2086 additions and 2402 deletions

View File

@ -1,98 +0,0 @@
/*
Copyright 2019 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/resource"
)
// Error strings.
const (
errBindClaimConflict = "cannot bind claim that references a different composite resource"
errGetSecret = "cannot get composite resource's connection secret"
errSecretConflict = "cannot establish control of existing connection secret"
errCreateOrUpdateSecret = "cannot create or update connection secret"
)
// An APIConnectionPropagator propagates connection details by reading
// them from and writing them to a Kubernetes API server.
type APIConnectionPropagator struct {
client resource.ClientApplicator
}
// NewAPIConnectionPropagator returns a new APIConnectionPropagator.
func NewAPIConnectionPropagator(c client.Client) *APIConnectionPropagator {
return &APIConnectionPropagator{
client: resource.ClientApplicator{Client: c, Applicator: resource.NewAPIUpdatingApplicator(c)},
}
}
// PropagateConnection details from the supplied resource.
func (a *APIConnectionPropagator) PropagateConnection(ctx context.Context, to resource.LocalConnectionSecretOwner, from resource.ConnectionSecretOwner) (bool, error) {
// Either from does not expose a connection secret, or to does not want one.
if from.GetWriteConnectionSecretToReference() == nil || to.GetWriteConnectionSecretToReference() == nil {
return false, nil
}
n := types.NamespacedName{
Namespace: from.GetWriteConnectionSecretToReference().Namespace,
Name: from.GetWriteConnectionSecretToReference().Name,
}
fs := &corev1.Secret{}
if err := a.client.Get(ctx, n, fs); err != nil {
return false, errors.Wrap(err, errGetSecret)
}
// Make sure 'from' is the controller of the connection secret it references
// before we propagate it. This ensures a resource cannot use Crossplane to
// circumvent RBAC by propagating a secret it does not own.
if c := metav1.GetControllerOf(fs); c == nil || c.UID != from.GetUID() {
return false, errors.New(errSecretConflict)
}
ts := resource.LocalConnectionSecretFor(to, to.GetObjectKind().GroupVersionKind())
ts.Data = fs.Data
err := a.client.Apply(ctx, ts,
resource.ConnectionSecretMustBeControllableBy(to.GetUID()),
resource.AllowUpdateIf(func(current, desired runtime.Object) bool {
// We consider the update to be a no-op and don't allow it if the
// current and existing secret data are identical.
return !cmp.Equal(current.(*corev1.Secret).Data, desired.(*corev1.Secret).Data, cmpopts.EquateEmpty())
}),
)
if resource.IsNotAllowed(err) {
// The update was not allowed because it was a no-op.
return false, nil
}
if err != nil {
return false, errors.Wrap(err, errCreateOrUpdateSecret)
}
return true, nil
}

View File

@ -1,308 +0,0 @@
/*
Copyright 2019 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"fmt"
"strings"
"dario.cat/mergo"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
corev1 "k8s.io/api/core/v1"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
"github.com/crossplane/crossplane/internal/names"
"github.com/crossplane/crossplane/internal/xcrd"
)
const (
errUnsupportedClaimSpec = "composite resource claim spec was not an object"
errUnsupportedDstObject = "destination object was not valid object"
errUnsupportedSrcObject = "source object was not valid object"
errMergeClaimSpec = "unable to merge claim spec"
errMergeClaimStatus = "unable to merge claim status"
)
var (
// ErrBindCompositeConflict can occur if the composite refers a different claim
ErrBindCompositeConflict = errors.New("cannot bind composite resource that references a different claim")
)
type apiCompositeConfigurator struct {
names.NameGenerator
}
// Configure configures the supplied composite patch
// by propagating configuration from the supplied claim.
// Both create and update scenarios are supported; i.e. the
// composite may or may not have been created in the API server
// when passed to this method.
func (c *apiCompositeConfigurator) Configure(ctx context.Context, cmObserved *claim.Unstructured, cpObserved, cpPatch *composite.Unstructured) error { //nolint:gocyclo // Only slightly over (12).
icmSpec := cmObserved.Object["spec"]
spec, ok := icmSpec.(map[string]any)
if !ok {
return errors.New(errUnsupportedClaimSpec)
}
existing := cpObserved.GetClaimReference()
proposed := cmObserved.GetReference()
if existing != nil && !cmp.Equal(existing, proposed) {
return ErrBindCompositeConflict
}
// It's possible we're being asked to configure a statically provisioned
// composite resource in which case we should respect its existing name and
// external name.
en := meta.GetExternalName(cpObserved)
// Do not propagate *.kubernetes.io annotations/labels down to the composite
// For example: when a claim gets deployed using kubectl,
// its kubectl.kubernetes.io/last-applied-configuration annotation
// should not be propagated to the corresponding composite resource,
// because:
// * XR was not created using kubectl
// * The content of the annotaton refers to the claim, not XR
// See https://kubernetes.io/docs/reference/labels-annotations-taints/
// for all annotations and their semantic
if ann := withoutReservedK8sEntries(cmObserved.GetAnnotations()); len(ann) > 0 {
meta.AddAnnotations(cpPatch, withoutReservedK8sEntries(cmObserved.GetAnnotations()))
}
meta.AddLabels(cpPatch, withoutReservedK8sEntries(cmObserved.GetLabels()))
meta.AddLabels(cpPatch, map[string]string{
xcrd.LabelKeyClaimName: cmObserved.GetName(),
xcrd.LabelKeyClaimNamespace: cmObserved.GetNamespace(),
})
// If our composite resource already exists we want to restore its
// original external name (if set) in order to ensure we don't try to
// rename anything after the fact.
if meta.WasCreated(cpObserved) && en != "" {
meta.SetExternalName(cpPatch, en)
}
// We want to propagate the claim's spec to the composite's spec, but
// first we must filter out any well-known fields that are unique to
// claims. We do this by:
// 1. Grabbing a map whose keys represent all well-known claim fields.
// 2. Deleting any well-known fields that we want to propagate.
// 3. Using the resulting map keys to filter the claim's spec.
wellKnownClaimFields := xcrd.CompositeResourceClaimSpecProps()
for _, field := range xcrd.PropagateSpecProps {
delete(wellKnownClaimFields, field)
}
// CompositionRevision is a special field which needs to be propagated
// based on the Update policy. If the policy is `Manual`, we need to
// remove CompositionRevisionRef from wellKnownClaimFields, so it
// does not get filtered out and is set correctly in composite
if cpObserved.GetCompositionUpdatePolicy() != nil && *cpObserved.GetCompositionUpdatePolicy() == xpv1.UpdateManual {
delete(wellKnownClaimFields, xcrd.CompositionRevisionRef)
}
claimSpecFilter := xcrd.GetPropFields(wellKnownClaimFields)
cpPatch.Object["spec"] = withoutKeys(spec, claimSpecFilter...)
// Note that we overwrite the entire composite spec above, so we wait
// until this point to set the claim reference. We compute the reference
// earlier so we can return early if it would not be allowed.
cpPatch.SetClaimReference(proposed)
if meta.WasCreated(cpObserved) {
cpPatch.SetName(cpObserved.GetName())
return nil
}
// The composite was not found in the informer cache,
// or in the apiserver watch cache,
// or really does not exist.
// If the claim contains the composite reference,
// try to use it to set the composite name.
// This protects us against stale caches:
// 1. If the composite exists, but the cache was not up-to-date,
// then its creation is going to fail, and after requeue,
// the cache eventually gets up-to-date and everything is good.
// 2. If the composite really does not exist, it means that
// the claim got bound in one of previous loop,
// but something went wrong at composite creation and we requeued.
// It is alright to try to use the very same name again.
if ref := cmObserved.GetResourceReference(); ref != nil &&
ref.APIVersion == cpObserved.GetAPIVersion() && ref.Kind == cpObserved.GetKind() {
cpPatch.SetName(ref.Name)
return nil
}
// Otherwise, generate name with a random suffix, hoping it is not already taken
cpObserved.SetGenerateName(fmt.Sprintf("%s-", cmObserved.GetName()))
// Generated name is likely (but not guaranteed) to be available
// when we create the composite resource. If taken,
// then we are going to update an existing composite,
// hijacking it from another claim. Depending on context/environment
// the consequences could be more or less serious.
// TODO: decide if we must prevent it.
if err := c.GenerateName(ctx, cpObserved); err != nil {
return err
}
cpPatch.SetName(cpObserved.GetName())
return nil
}
func withoutReservedK8sEntries(a map[string]string) map[string]string {
for k := range a {
s := strings.Split(k, "/")
if strings.HasSuffix(s[0], "kubernetes.io") || strings.HasSuffix(s[0], "k8s.io") {
delete(a, k)
}
}
return a
}
func withoutKeys(in map[string]any, keys ...string) map[string]any {
filter := map[string]bool{}
for _, k := range keys {
filter[k] = true
}
out := map[string]any{}
for k, v := range in {
if filter[k] {
continue
}
out[k] = v
}
return out
}
func onlyKeys(in map[string]any, keys ...string) map[string]any {
filter := map[string]bool{}
for _, k := range keys {
filter[k] = true
}
out := map[string]any{}
for k, v := range in {
if filter[k] {
out[k] = v
}
}
return out
}
// configure the supplied claim patch with fields from the composite.
// This includes late-initializing spec values and updating status fields in claim.
func configureClaim(_ context.Context, cmObserved *claim.Unstructured, cmPatch *claim.Unstructured, cpObserved *composite.Unstructured, cpPatch *composite.Unstructured) error { //nolint:gocyclo // Only slightly over (10)
existing := cmObserved.GetResourceReference()
proposed := meta.ReferenceTo(cpPatch, cpPatch.GetObjectKind().GroupVersionKind())
equal := cmp.Equal(existing, proposed, cmpopts.IgnoreFields(corev1.ObjectReference{}, "UID"))
// We refuse to 're-bind' a claim that is already bound to a different
// composite resource.
if existing != nil && !equal {
return errors.New(errBindClaimConflict)
}
cmPatch.SetResourceReference(proposed)
cmPatch.Object["status"] = map[string]any{}
// If existing claim has the status set,
// copy the conditions to the patch
if s, ok := cmObserved.Object["status"]; ok {
fs, ok := s.(map[string]any)
if !ok {
return errors.Wrap(errors.New(errUnsupportedSrcObject), errMergeClaimStatus)
}
cmPatch.Object["status"] = onlyKeys(fs, "conditions")
}
// merge from the composite status everything
// except conditions and connectionDetails
if v := cpObserved.Object["status"]; v != nil {
cpObservedStatus, ok := v.(map[string]any)
if !ok {
return errors.Wrap(errors.New(errUnsupportedSrcObject), errMergeClaimStatus)
}
if err := merge(cmPatch.Object["status"], withoutKeys(cpObservedStatus, xcrd.GetPropFields(xcrd.CompositeResourceStatusProps())...),
// Status fields from composite overwrite non-empty fields in claim
mergo.WithOverride); err != nil {
return errors.Wrap(err, errMergeClaimStatus)
}
}
// Propagate the actual external name back from the composite to the
// claim if it's set. The name we're propagating here will may be a name
// the XR must enforce (i.e. overriding any requested by the claim) but
// will often actually just be propagating back a name that was already
// propagated forward from the claim to the XR during the
// preceding configure phase.
if en := meta.GetExternalName(cpObserved); en != "" {
meta.SetExternalName(cmPatch, en)
}
// CompositionRevision is a special field which needs to be propagated
// based on the Update policy. If the policy is `Automatic`, we need to
// overwrite the claim's value with the composite's which should be the
// `currentRevision`
if cpObserved.GetCompositionUpdatePolicy() != nil &&
*cpObserved.GetCompositionUpdatePolicy() == xpv1.UpdateAutomatic && cpObserved.GetCompositionRevisionReference() != nil {
cmPatch.SetCompositionRevisionReference(cpObserved.GetCompositionRevisionReference())
}
// propagate to the claim only the following fields:
// - "compositionRef"
// - "compositionSelector"
// - "compositionUpdatePolicy"
// - "compositionRevisionSelector"
if v := cpObserved.Object["spec"]; v != nil {
cpObservedSpec, ok := v.(map[string]any)
if !ok {
return errors.Wrap(errors.New(errUnsupportedSrcObject), errMergeClaimSpec)
}
if err := merge(cmPatch.Object["spec"], onlyKeys(cpObservedSpec, xcrd.PropagateSpecProps...)); err != nil {
return errors.Wrap(err, errMergeClaimSpec)
}
}
return nil
}
// merge a src map into dst map
func merge(dst, src any, opts ...func(*mergo.Config)) error {
if dst == nil || src == nil {
// Nothing available to merge if dst or src are nil.
// This can occur early on in reconciliation when the
// status subresource has not been set yet.
return nil
}
dstMap, ok := dst.(map[string]any)
if !ok {
return errors.New(errUnsupportedDstObject)
}
srcMap, ok := src.(map[string]any)
if !ok {
return errors.New(errUnsupportedSrcObject)
}
return mergo.Merge(&dstMap, srcMap, opts...)
}

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,27 @@ package claim
import (
"context"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
"github.com/crossplane/crossplane-runtime/pkg/resource"
)
// Error strings.
const (
errGetSecret = "cannot get composite resource's connection secret"
errSecretConflict = "cannot establish control of existing connection secret"
errCreateOrUpdateSecret = "cannot create or update connection secret"
)
// NopConnectionUnpublisher is a ConnectionUnpublisher that does nothing.
type NopConnectionUnpublisher struct{}
@ -69,3 +85,61 @@ func (s soClaim) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
// just to satisfy resource.ConnectionSecretOwner interface.
return nil
}
// An APIConnectionPropagator propagates connection details by reading
// them from and writing them to a Kubernetes API server.
type APIConnectionPropagator struct {
client resource.ClientApplicator
}
// NewAPIConnectionPropagator returns a new APIConnectionPropagator.
func NewAPIConnectionPropagator(c client.Client) *APIConnectionPropagator {
return &APIConnectionPropagator{
client: resource.ClientApplicator{Client: c, Applicator: resource.NewAPIUpdatingApplicator(c)},
}
}
// PropagateConnection details from the supplied resource.
func (a *APIConnectionPropagator) PropagateConnection(ctx context.Context, to resource.LocalConnectionSecretOwner, from resource.ConnectionSecretOwner) (bool, error) {
// Either from does not expose a connection secret, or to does not want one.
if from.GetWriteConnectionSecretToReference() == nil || to.GetWriteConnectionSecretToReference() == nil {
return false, nil
}
n := types.NamespacedName{
Namespace: from.GetWriteConnectionSecretToReference().Namespace,
Name: from.GetWriteConnectionSecretToReference().Name,
}
fs := &corev1.Secret{}
if err := a.client.Get(ctx, n, fs); err != nil {
return false, errors.Wrap(err, errGetSecret)
}
// Make sure 'from' is the controller of the connection secret it references
// before we propagate it. This ensures a resource cannot use Crossplane to
// circumvent RBAC by propagating a secret it does not own.
if c := metav1.GetControllerOf(fs); c == nil || c.UID != from.GetUID() {
return false, errors.New(errSecretConflict)
}
ts := resource.LocalConnectionSecretFor(to, to.GetObjectKind().GroupVersionKind())
ts.Data = fs.Data
err := a.client.Apply(ctx, ts,
resource.ConnectionSecretMustBeControllableBy(to.GetUID()),
resource.AllowUpdateIf(func(current, desired runtime.Object) bool {
// We consider the update to be a no-op and don't allow it if the
// current and existing secret data are identical.
return !cmp.Equal(current.(*corev1.Secret).Data, desired.(*corev1.Secret).Data, cmpopts.EquateEmpty())
}),
)
if resource.IsNotAllowed(err) {
// The update was not allowed because it was a no-op.
return false, nil
}
if err != nil {
return false, errors.Wrap(err, errCreateOrUpdateSecret)
}
return true, nil
}

View File

@ -0,0 +1,104 @@
/*
Copyright 2024 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"strings"
"dario.cat/mergo"
"github.com/crossplane/crossplane-runtime/pkg/errors"
)
const (
errUnsupportedDstObject = "destination object was not valid object"
errUnsupportedSrcObject = "source object was not valid object"
)
func withoutReservedK8sEntries(a map[string]string) map[string]string {
for k := range a {
s := strings.Split(k, "/")
if strings.HasSuffix(s[0], "kubernetes.io") || strings.HasSuffix(s[0], "k8s.io") {
delete(a, k)
}
}
return a
}
func withoutKeys(in map[string]any, keys ...string) map[string]any {
filter := map[string]bool{}
for _, k := range keys {
filter[k] = true
}
out := map[string]any{}
for k, v := range in {
if filter[k] {
continue
}
out[k] = v
}
return out
}
type mergeConfig struct {
mergeOptions []func(*mergo.Config)
srcfilter []string
}
// withMergeOptions allows custom mergo.Config options
func withMergeOptions(opts ...func(*mergo.Config)) func(*mergeConfig) {
return func(config *mergeConfig) {
config.mergeOptions = opts
}
}
// withSrcFilter filters supplied keys from src map before merging
func withSrcFilter(keys ...string) func(*mergeConfig) {
return func(config *mergeConfig) {
config.srcfilter = keys
}
}
// merge a src map into dst map
func merge(dst, src any, opts ...func(*mergeConfig)) error {
if dst == nil || src == nil {
// Nothing available to merge if dst or src are nil.
// This can occur early on in reconciliation when the
// status subresource has not been set yet.
return nil
}
config := &mergeConfig{}
for _, opt := range opts {
opt(config)
}
dstMap, ok := dst.(map[string]any)
if !ok {
return errors.New(errUnsupportedDstObject)
}
srcMap, ok := src.(map[string]any)
if !ok {
return errors.New(errUnsupportedSrcObject)
}
return mergo.Merge(&dstMap, withoutKeys(srcMap, config.srcfilter...), config.mergeOptions...)
}

View File

@ -25,7 +25,6 @@ import (
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/manager"
@ -48,49 +47,35 @@ import (
const (
finalizer = "finalizer.apiextensions.crossplane.io"
reconcileTimeout = 1 * time.Minute
// fieldOwnerName used by all claim controllers when patch/update requests
// are sent to API server.
// Upstream k8s uses a unique field owner name per controller.
// During the review, we moved towards the use of a single name
// for all claim controllers, see this thread for more details:
// https://github.com/crossplane/crossplane/pull/4896#discussion_r1392023074
fieldOwnerName = "apiextensions.crossplane.io/claim"
)
// Error strings.
const (
errGetClaim = "cannot get composite resource claim"
errGetComposite = "cannot get referenced composite resource"
errDeleteComposite = "cannot delete referenced composite resource"
errDeleteUnbound = "refusing to delete composite resource that is not bound to this claim"
errDeleteCDs = "cannot delete connection details"
errRemoveFinalizer = "cannot remove composite resource claim finalizer"
errConfigureComposite = "cannot configure composite resource"
errPatchComposite = "cannot patch composite resource"
errFixFieldOwnershipComposite = "cannot fix field ownerships on composite resource"
errFixFieldOwnershipClaim = "cannot fix field ownerships on claim resource"
errConfigureClaim = "cannot configure composite resource claim"
errPropagateCDs = "cannot propagate connection details from composite"
errGetClaim = "cannot get claim"
errGetComposite = "cannot get bound composite resource"
errDeleteComposite = "cannot delete bound composite resource"
errDeleteCDs = "cannot delete connection details"
errRemoveFinalizer = "cannot remove finalizer from claim"
errAddFinalizer = "cannot add finalizer to claim"
errUpgradeManagedFields = "cannot upgrade composite resource's managed fields from client-side to server-side apply"
errSync = "cannot bind and sync claim with composite resource"
errPropagateCDs = "cannot propagate connection details from composite resource"
errUpdateClaimStatus = "cannot update claim status"
errUpdateClaimStatus = "cannot update composite resource claim status"
reconcilePausedMsg = "Reconciliation (including deletion) is paused via the pause annotation"
errFmtUnbound = "refusing to operate on composite resource %q that is not bound to this claim: bound to claim %q"
)
const reconcilePausedMsg = "Reconciliation (including deletion) is paused via the pause annotation"
// Event reasons.
const (
reasonBind event.Reason = "BindCompositeResource"
reasonDelete event.Reason = "DeleteCompositeResource"
reasonCompositeConfigure event.Reason = "ConfigureCompositeResource"
reasonClaimConfigure event.Reason = "ConfigureClaim"
reasonPropagate event.Reason = "PropagateConnectionSecret"
reasonPaused event.Reason = "ReconciliationPaused"
)
var (
// field manager names used by previous Crossplane versions
csaManagerNames = []string{"Go-http-client", "crossplane"}
reasonBind event.Reason = "BindCompositeResource"
reasonDelete event.Reason = "DeleteCompositeResource"
reasonCompositeConfigure event.Reason = "ConfigureCompositeResource"
reasonClaimConfigure event.Reason = "ConfigureClaim"
reasonClaimSelectDefaults event.Reason = "SelectClaimDefaults"
reasonPropagate event.Reason = "PropagateConnectionSecret"
reasonPaused event.Reason = "ReconciliationPaused"
)
// ControllerName returns the recommended name for controllers that use this
@ -99,8 +84,28 @@ func ControllerName(name string) string {
return "claim/" + name
}
type compositeConfiguratorFn func(ctx context.Context, cm *claim.Unstructured, cp, desiredCp *composite.Unstructured) error
type claimConfiguratorFn func(ctx context.Context, cm, desiredCm *claim.Unstructured, cp, desiredCp *composite.Unstructured) error
// A ManagedFieldsUpgrader upgrades an objects managed fields from client-side
// apply to server-side apply. This is necessary when an object was previously
// managed using client-side apply, but should now be managed using server-side
// apply. See https://github.com/kubernetes/kubernetes/issues/99003 for details.
type ManagedFieldsUpgrader interface {
Upgrade(ctx context.Context, obj client.Object, ssaManager string, csaManagers ...string) error
}
// A CompositeSyncer binds and syncs the supplied claim with the supplied
// composite resource (XR).
type CompositeSyncer interface {
Sync(ctx context.Context, cm *claim.Unstructured, xr *composite.Unstructured) error
}
// A CompositeSyncerFn binds and syncs the supplied claim with the supplied
// composite resource (XR).
type CompositeSyncerFn func(ctx context.Context, cm *claim.Unstructured, xr *composite.Unstructured) error
// Sync the supplied claim with the supplied composite resource..
func (fn CompositeSyncerFn) Sync(ctx context.Context, cm *claim.Unstructured, xr *composite.Unstructured) error {
return fn(ctx, cm, xr)
}
// A ConnectionPropagator is responsible for propagating information required to
// connect to a resource.
@ -168,20 +173,22 @@ func (fn DefaultsSelectorFn) SelectDefaults(ctx context.Context, cm resource.Com
return fn(ctx, cm)
}
// A Reconciler reconciles composite resource claims by creating exactly one kind of
// concrete composite resource. Each composite resource claim kind should create an instance
// of this controller for each composite resource kind they can bind to, using
// watch predicates to ensure each controller is responsible for exactly one
// type of resource class provisioner. Each controller must watch its subset of
// composite resource claims and any composite resources they control.
// A Reconciler reconciles claims by creating exactly one kind of composite
// resource (XR). Each claim kind should create an instance of this controller
// for each XR kind they can bind to. Each controller must watch its subset of
// claims and any XRs they bind to.
type Reconciler struct {
client client.Client
newClaim func() *claim.Unstructured
newComposite func() *composite.Unstructured
client client.Client
gvkClaim schema.GroupVersionKind
gvkXR schema.GroupVersionKind
managedFields ManagedFieldsUpgrader
// The below structs embed the set of interfaces used to implement the
// composite resource claim reconciler. We do this primarily for readability, so that
// the reconciler logic reads r.composite.Create(), r.claim.Finalize(), etc.
// composite resource claim reconciler. We do this primarily for
// readability, so that the reconciler logic reads r.composite.Sync(),
// r.claim.Finalize(), etc.
composite crComposite
claim crClaim
@ -191,21 +198,20 @@ type Reconciler struct {
}
type crComposite struct {
Configure compositeConfiguratorFn
CompositeSyncer
ConnectionPropagator
}
func defaultCRComposite(c client.Client) crComposite {
conf := &apiCompositeConfigurator{NameGenerator: names.NewNameGenerator(c)}
return crComposite{
Configure: conf.Configure,
// TODO(negz): Use CSA syncer unless feature flag is enabled.
CompositeSyncer: NewServerSideCompositeSyncer(c, names.NewNameGenerator(c)),
ConnectionPropagator: NewAPIConnectionPropagator(c),
}
}
type crClaim struct {
resource.Finalizer
Configure claimConfiguratorFn
ConnectionUnpublisher
}
@ -213,34 +219,34 @@ func defaultCRClaim(c client.Client) crClaim {
return crClaim{
Finalizer: resource.NewAPIFinalizer(c, finalizer),
ConnectionUnpublisher: NewNopConnectionUnpublisher(),
Configure: configureClaim,
}
}
// A ReconcilerOption configures a Reconciler.
type ReconcilerOption func(*Reconciler)
// WithClient specifies how the Reconciler should interact with the
// Kubernetes API.
// WithClient specifies how the Reconciler should interact with the Kubernetes
// API.
func WithClient(c client.Client) ReconcilerOption {
return func(r *Reconciler) {
r.client = c
}
}
// withCompositeConfigurator specifies how the Reconciler should configure the bound
// composite resource.
func withCompositeConfigurator(cf compositeConfiguratorFn) ReconcilerOption {
// WithManagedFieldsUpgrader specifies how the Reconciler should upgrade claim
// and composite resource (XR) managed fields from client-side apply to
// server-side apply.
func WithManagedFieldsUpgrader(u ManagedFieldsUpgrader) ReconcilerOption {
return func(r *Reconciler) {
r.composite.Configure = cf
r.managedFields = u
}
}
// WithClaimConfigurator specifies how the Reconciler should configure the bound
// claim resource.
func withClaimConfigurator(cf claimConfiguratorFn) ReconcilerOption {
// WithCompositeSyncer specifies how the Reconciler should sync claims with
// composite resources (XRs).
func WithCompositeSyncer(cs CompositeSyncer) ReconcilerOption {
return func(r *Reconciler) {
r.claim.Configure = cf
r.composite.CompositeSyncer = cs
}
}
@ -301,17 +307,14 @@ func WithPollInterval(after time.Duration) ReconcilerOption {
func NewReconciler(m manager.Manager, of resource.CompositeClaimKind, with resource.CompositeKind, o ...ReconcilerOption) *Reconciler {
c := unstructured.NewClient(m.GetClient())
r := &Reconciler{
client: c,
newClaim: func() *claim.Unstructured {
return claim.New(claim.WithGroupVersionKind(schema.GroupVersionKind(of)))
},
newComposite: func() *composite.Unstructured {
return composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind(with)))
},
composite: defaultCRComposite(c),
claim: defaultCRClaim(c),
log: logging.NewNopLogger(),
record: event.NewNopRecorder(),
client: c,
gvkClaim: schema.GroupVersionKind(of),
gvkXR: schema.GroupVersionKind(with),
managedFields: NewPatchingManagedFieldsUpgrader(c), // TODO(negz): Use Nop unless flag is enabled.
composite: defaultCRComposite(c),
claim: defaultCRClaim(c),
log: logging.NewNopLogger(),
record: event.NewNopRecorder(),
}
for _, ro := range o {
@ -323,17 +326,16 @@ func NewReconciler(m manager.Manager, of resource.CompositeClaimKind, with resou
// Reconcile a composite resource claim with a concrete composite resource.
func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { //nolint:gocyclo // Complexity is tough to avoid here.
log := r.log.WithValues("request", req)
log.Debug("Reconciling")
ctx, cancel := context.WithTimeout(ctx, reconcileTimeout)
defer cancel()
cm := r.newClaim()
cm := claim.New(claim.WithGroupVersionKind(r.gvkClaim))
if err := r.client.Get(ctx, req.NamespacedName, cm); err != nil {
// There's no need to requeue if we no longer exist. Otherwise
// we'll be requeued implicitly because we return an error.
// There's no need to requeue if we no longer exist. Otherwise we'll be
// requeued implicitly because we return an error.
log.Debug(errGetClaim, "error", err)
return reconcile.Result{}, errors.Wrap(resource.IgnoreNotFound(err), errGetClaim)
}
@ -345,84 +347,88 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
"external-name", meta.GetExternalName(cm),
)
// Previous versions of crossplane did not use server-side apply for updating claims.
// We need to fix the manager name so that future reconciliations do not
// create shared field ownership between old and actual managers
if ownershipFixed := r.maybeFixFieldOwnership(cm.GetUnstructured(), true, fieldOwnerName, csaManagerNames...); ownershipFixed {
if err := r.client.Update(ctx, cm); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
record.Event(cm, event.Warning(reasonClaimConfigure, err))
return reconcile.Result{Requeue: true}, errors.Wrap(err, errFixFieldOwnershipClaim)
}
}
// Check the pause annotation and return if it has the value "true"
// after logging, publishing an event and updating the SYNC status condition
// Check the pause annotation and return if it has the value "true" after
// logging, publishing an event and updating the Synced status condition.
if meta.IsPaused(cm) {
r.record.Event(cm, event.Normal(reasonPaused, reconcilePausedMsg))
cm.SetConditions(xpv1.ReconcilePaused().WithMessage(reconcilePausedMsg))
// If the pause annotation is removed, we will have a chance to reconcile again and resume
// and if status update fails, we will reconcile again to retry to update the status
return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
// If the pause annotation is removed, we will have a chance to
// reconcile again and resume and if status update fails, we will
// reconcile again to retry to update the status.
return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
cp := r.newComposite()
xr := composite.New(composite.WithGroupVersionKind(r.gvkXR))
if ref := cm.GetResourceReference(); ref != nil {
record = record.WithAnnotations("composite-name", cm.GetResourceReference().Name)
log = log.WithValues("composite-name", cm.GetResourceReference().Name)
if err := r.client.Get(ctx, meta.NamespacedNameOf(ref), cp); resource.IgnoreNotFound(err) != nil {
if err := r.client.Get(ctx, meta.NamespacedNameOf(ref), xr); resource.IgnoreNotFound(err) != nil {
err = errors.Wrap(err, errGetComposite)
record.Event(cm, event.Warning(reasonBind, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
}
// Return early if the claim references an XR that doesn't reference it.
//
// We don't requeue in this situation because the claim will need human
// intervention before we can proceed (e.g. fixing the ref), and we'll be
// queued implicitly when the claim is edited.
//
// A claim might be able to delete an XR it's not bound to, as long as the
// XR is bindable but not yet bound. This is because we only check the claim
// ref if the XR has one - this allows us to bind unbound claims. Given that
// the claim could bind this XR, then be deleted and in turn delete the XR
// this is not an issue.
if ref := xr.GetClaimReference(); meta.WasCreated(xr) && ref != nil && !cmp.Equal(cm.GetReference(), ref) {
err := errors.Errorf(errFmtUnbound, xr.GetName(), ref.Name)
record.Event(cm, event.Warning(reasonBind, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
// TODO(negz): Remove this call to Upgrade once no supported version of
// Crossplane uses client-side apply to sync claims with XRs. We only need
// to upgrade field managers if _this controller_ might have applied the XR
// before using the default client-side apply field manager "crossplane",
// but now wants to use server-side apply instead.
if err := r.managedFields.Upgrade(ctx, xr, FieldOwnerXR, "crossplane"); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
err = errors.Wrap(err, errUpgradeManagedFields)
record.Event(cm, event.Warning(reasonBind, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
if meta.WasDeleted(cm) {
log = log.WithValues("deletion-timestamp", cm.GetDeletionTimestamp())
cm.SetConditions(xpv1.Deleting())
if meta.WasCreated(cp) {
if meta.WasCreated(xr) {
requiresForegroundDeletion := false
if cdp := cm.GetCompositeDeletePolicy(); cdp != nil && *cdp == xpv1.CompositeDeleteForeground {
requiresForegroundDeletion = true
}
if meta.WasDeleted(cp) {
if requiresForegroundDeletion {
log.Debug("Waiting for the Composite to finish deleting (foreground deletion)")
return reconcile.Result{Requeue: true}, nil
}
if meta.WasDeleted(xr) && requiresForegroundDeletion {
log.Debug("Waiting for the XR to finish deleting (foreground deletion)")
return reconcile.Result{Requeue: true}, nil
}
ref := cp.GetClaimReference()
want := cm.GetReference()
if !cmp.Equal(want, ref) {
// We don't requeue (or return an error, which
// would requeue) in this situation because the
// claim will need human intervention before we
// can proceed (e.g. fixing the ref), and we'll
// be queued implicitly when the claim is
// edited.
err := errors.New(errDeleteUnbound)
record.Event(cm, event.Warning(reasonDelete, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
}
do := &client.DeleteOptions{}
if requiresForegroundDeletion {
client.PropagationPolicy(metav1.DeletePropagationForeground).ApplyToDelete(do)
}
if err := r.client.Delete(ctx, cp, do); resource.IgnoreNotFound(err) != nil {
if err := r.client.Delete(ctx, xr, do); resource.IgnoreNotFound(err) != nil {
err = errors.Wrap(err, errDeleteComposite)
record.Event(cm, event.Warning(reasonDelete, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
if requiresForegroundDeletion {
log.Debug("Requeue to wait for the Composite to finish deleting (foreground deletion)")
log.Debug("Waiting for the XR to finish deleting (foreground deletion)")
return reconcile.Result{Requeue: true}, nil
}
}
@ -434,7 +440,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
err = errors.Wrap(err, errDeleteCDs)
record.Event(cm, event.Warning(reasonDelete, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
record.Event(cm, event.Normal(reasonDelete, "Successfully deleted composite resource"))
@ -443,125 +449,57 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
err = errors.Wrap(err, errRemoveFinalizer)
record.Event(cm, event.Warning(reasonDelete, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
log.Debug("Successfully deleted composite resource claim")
log.Debug("Successfully deleted claim")
cm.SetConditions(xpv1.ReconcileSuccess())
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
// If composite exists, i.e. got created in the previous iteration,
// it might be needed to fix field ownerships,
// so that the claim controller becomes an exclusive owner
// of the fields mirrored from the claim to the composite
if meta.WasCreated(cp) {
if ownershipFixed := r.maybeFixFieldOwnership(cp.GetUnstructured(), false, fieldOwnerName, csaManagerNames...); ownershipFixed {
if err := r.client.Update(ctx, cp); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
record.Event(cp, event.Warning(reasonCompositeConfigure, err))
return reconcile.Result{Requeue: true}, errors.Wrap(err, errFixFieldOwnershipComposite)
}
}
}
// create object that is going to hold the full claim patch eventually
cmPatch := r.newClaim()
cmPatch.SetName(cm.GetName())
cmPatch.SetNamespace(cm.GetNamespace())
meta.AddFinalizer(cmPatch, finalizer)
// create object that is going to hold the full composite patch eventually
cpPatch := r.newComposite()
if err := r.composite.Configure(ctx, cm, cp, cpPatch); err != nil {
if err := r.claim.AddFinalizer(ctx, cm); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
err = errors.Wrap(err, errConfigureComposite)
record.Event(cm, event.Warning(reasonCompositeConfigure, err))
err = errors.Wrap(err, errAddFinalizer)
record.Event(cm, event.Warning(reasonBind, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
// We'll know our composite resource's name at this point because it was
// set by the above configure step.
record = record.WithAnnotations("composite-name", cpPatch.GetName())
log = log.WithValues("composite-name", cpPatch.GetName())
if err := r.claim.Configure(ctx, cm, cmPatch, cp, cpPatch); err != nil {
// Create (if necessary), bind, and sync an XR with the claim.
if err := r.composite.Sync(ctx, cm, xr); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
err = errors.Wrap(err, errConfigureClaim)
record.Event(cm, event.Warning(reasonClaimConfigure, err))
err = errors.Wrap(err, errSync)
record.Event(cm, event.Warning(reasonBind, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
// The following patch operation is going to override the status part of the claim patch
// with the status of the actual claim version.
// given that the status update come later, we need to preserve it temporarily.
desiredClaimStatus := cmPatch.Object["status"]
log.Debug("Patching claim", "patch", cmPatch.Object)
err := r.client.Patch(ctx, cmPatch, client.Apply, client.ForceOwnership, client.FieldOwner(fieldOwnerName))
if err != nil {
return reconcile.Result{}, err
}
if cmPatch.GetResourceVersion() == cm.GetResourceVersion() {
log.Debug("Patching claim was no-op")
}
cm = cmPatch
// Restore the status calculated in this round
// and use cmPatch until the end of the reconciliation
// for setting claim conditions
cm.Object["status"] = desiredClaimStatus
// If composite did not exist at the beginning of the loop, we want to create it
// but there is a probability (very unlikely) that the generated name
// gets taken before we submit the request. In that case, we are going to
// update a composite, hijacking it from another claim.
// Reported in https://github.com/crossplane/crossplane/issues/5104
// TODO: Investigate if we need to prevent it.
log.Debug("Patching composite", "patch", cpPatch.Object)
if err = r.client.Patch(ctx, cpPatch, client.Apply, client.ForceOwnership, client.FieldOwner(fieldOwnerName)); err != nil {
if kerrors.IsConflict(err) {
return reconcile.Result{Requeue: true}, nil
}
err = errors.Wrap(err, errPatchComposite)
record.Event(cm, event.Warning(reasonCompositeConfigure, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
}
if cpPatch.GetResourceVersion() == cp.GetResourceVersion() {
log.Debug("Composite patch was no-op.")
} else {
record.Event(cm, event.Normal(reasonCompositeConfigure, "Successfully patched composite resource"))
}
cp = cpPatch
cm.SetConditions(xpv1.ReconcileSuccess())
if !resource.IsConditionTrue(cp.GetCondition(xpv1.TypeReady)) {
if !resource.IsConditionTrue(xr.GetCondition(xpv1.TypeReady)) {
record.Event(cm, event.Normal(reasonBind, "Composite resource is not yet ready"))
// We should be watching the composite resource and will have a
// request queued if it changes, so no need to requeue.
cm.SetConditions(Waiting())
return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
record.Event(cm, event.Normal(reasonBind, "Composite resource is ready"))
// TODO(negz): Don't emit this event every time we reconcile. Perhaps emit
// if if the XR doesn't reference this claim before calling sync, but does
// after.
record.Event(cm, event.Normal(reasonBind, "Successfully bound composite resource"))
propagated, err := r.composite.PropagateConnection(ctx, cm, cp)
propagated, err := r.composite.PropagateConnection(ctx, cm, xr)
if err != nil {
err = errors.Wrap(err, errPropagateCDs)
record.Event(cm, event.Warning(reasonPropagate, err))
cm.SetConditions(xpv1.ReconcileError(err))
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
if propagated {
cm.SetConnectionDetailsLastPublishedTime(&metav1.Time{Time: time.Now()})
@ -571,46 +509,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
// We have a watch on both the claim and its composite, so there's no
// need to requeue here.
cm.SetConditions(xpv1.Available())
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm, client.FieldOwner(fieldOwnerName)), errUpdateClaimStatus)
}
// For the background, check https://github.com/kubernetes/kubernetes/issues/99003
// Managed fields owner key is a pair (manager name, used operation).
// Previous versions of Crossplane create a composite using patching applicator.
// Even if the server-side apply is not used, api server derives manager name
// from the submitted user agent (see net/http/request.go).
// After Crossplane update, we need to replace the ownership so that
// field removals can be propagated properly.
// In order to fix that, we need to manually change operation to "Apply",
// and the manager name, before the first composite patch is sent to k8s api server.
// Returns true if the ownership was fixed.
// TODO: this code can be removed once Crossplane v1.13 is not longer supported
func (r *Reconciler) maybeFixFieldOwnership(obj *kunstructured.Unstructured, fixSubresource bool, ssaManagerName string, csaManagerNames ...string) bool {
mfs := obj.GetManagedFields()
umfs := make([]metav1.ManagedFieldsEntry, len(mfs))
copy(umfs, mfs)
fixed := false
for j := range csaManagerNames {
for i := range umfs {
if umfs[i].Manager == ssaManagerName {
return false
}
if umfs[i].Subresource != "" && !fixSubresource {
continue
}
if umfs[i].Manager == csaManagerNames[j] && umfs[i].Operation == metav1.ManagedFieldsOperationUpdate {
umfs[i].Operation = metav1.ManagedFieldsOperationApply
umfs[i].Manager = ssaManagerName
fixed = true
}
}
}
if fixed {
obj.SetManagedFields(umfs)
}
return fixed
return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}
// Waiting returns a condition that indicates the composite resource claim is
@ -621,6 +520,6 @@ func Waiting() xpv1.Condition {
Status: corev1.ConditionFalse,
LastTransitionTime: metav1.Now(),
Reason: xpv1.ConditionReason("Waiting"),
Message: "Composite resource claim is waiting for composite resource to become Ready",
Message: "Claim is waiting for composite resource to become Ready",
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,221 @@
/*
Copyright 2024 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"fmt"
"dario.cat/mergo"
"github.com/google/go-cmp/cmp"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
"github.com/crossplane/crossplane/internal/names"
"github.com/crossplane/crossplane/internal/xcrd"
)
const (
errUpdateClaim = "cannot update claim"
errUnsupportedClaimSpec = "claim spec was not an object"
errBindCompositeConflict = "cannot bind composite resource that references a different claim"
errGenerateName = "cannot generate a name for composite resource"
errApplyComposite = "cannot apply composite resource"
errMergeClaimSpec = "unable to merge claim spec"
errMergeClaimStatus = "unable to merge claim status"
)
// A ClientSideCompositeSyncer binds and syncs a claim with a composite resource
// (XR). It uses client-side apply to update the claim and the composite.
type ClientSideCompositeSyncer struct {
client resource.ClientApplicator
names names.NameGenerator
}
// NewClientSideCompositeSyncer returns a CompositeSyncer that uses client-side
// apply to sync a claim with a composite resource.
func NewClientSideCompositeSyncer(c client.Client, ng names.NameGenerator) *ClientSideCompositeSyncer {
return &ClientSideCompositeSyncer{
client: resource.ClientApplicator{
Client: c,
Applicator: resource.NewAPIPatchingApplicator(c),
},
names: ng,
}
}
// Sync the supplied claim with the supplied composite resource (XR). Syncing
// may involve creating and binding the XR.
func (s *ClientSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstructured, xr *composite.Unstructured) error { //nolint:gocyclo // This complex process seems easier to follow in one long method.
// Refuse to bind and sync an XR bound to a different claim.
existing := xr.GetClaimReference()
proposed := cm.GetReference()
if existing != nil && !cmp.Equal(existing, proposed) {
return errors.New(errBindCompositeConflict)
}
// First we sync claim -> XR.
// It's possible we're being asked to configure a statically provisioned XR.
// We should respect its existing external name annotation.
en := meta.GetExternalName(xr)
// Do not propagate *.kubernetes.io annotations/labels from claim to XR. For
// example kubectl.kubernetes.io/last-applied-configuration should not be
// propagated.
// See https://kubernetes.io/docs/reference/labels-annotations-taints/
// for all annotations and their semantic
meta.AddAnnotations(xr, withoutReservedK8sEntries(cm.GetAnnotations()))
meta.AddLabels(xr, withoutReservedK8sEntries(cm.GetLabels()))
meta.AddLabels(xr, map[string]string{
xcrd.LabelKeyClaimName: cm.GetName(),
xcrd.LabelKeyClaimNamespace: cm.GetNamespace(),
})
// If the bound XR already exists we want to restore its original external
// name annotation in order to ensure we don't try to rename anything after
// the fact.
if meta.WasCreated(xr) && en != "" {
meta.SetExternalName(xr, en)
}
// We want to propagate the claim's spec to the composite's spec, but first
// we must filter out any well-known fields that are unique to claims. We do
// this by:
// 1. Grabbing a map whose keys represent all well-known claim fields.
// 2. Deleting any well-known fields that we want to propagate.
// 3. Using the resulting map keys to filter the claim's spec.
wellKnownClaimFields := xcrd.CompositeResourceClaimSpecProps()
for _, field := range xcrd.PropagateSpecProps {
delete(wellKnownClaimFields, field)
}
// CompositionRevisionRef is a special field which needs to be propagated
// based on the Update policy. If the policy is `Manual`, we need to remove
// CompositionRevisionRef from wellKnownClaimFields, so it is propagated
// from the claim to the XR.
if xr.GetCompositionUpdatePolicy() != nil && *xr.GetCompositionUpdatePolicy() == xpv1.UpdateManual {
delete(wellKnownClaimFields, xcrd.CompositionRevisionRef)
}
cmSpec, ok := cm.Object["spec"].(map[string]any)
if !ok {
return errors.New(errUnsupportedClaimSpec)
}
// Propagate the claim's spec (minus well known fields) to the XR's spec.
xr.Object["spec"] = withoutKeys(cmSpec, xcrd.GetPropFields(wellKnownClaimFields)...)
// We overwrite the entire XR spec above, so we wait until this point to set
// the claim reference.
xr.SetClaimReference(proposed)
// If the claim references an XR, make sure we're going to apply that XR. We
// do this just in case the XR exists, but we couldn't get it due to a stale
// cache.
if ref := cm.GetResourceReference(); ref != nil {
xr.SetName(ref.Name)
}
// If the XR doesn't exist, derive a name from the claim. The generated name
// is likely (but not guaranteed) to be available when we create the XR. If
// taken, then we are going to update an existing XR, probably hijacking it
// from another claim.
if !meta.WasCreated(xr) {
xr.SetGenerateName(fmt.Sprintf("%s-", cm.GetName()))
// GenerateName is a no-op if the xr already has a name set.
if err := s.names.GenerateName(ctx, xr); err != nil {
return errors.Wrap(err, errGenerateName)
}
}
// We're now done syncing the XR from the claim. If this is a new XR it's
// important that we update the claim to reference it before we create it.
// This ensures we don't leak an XR. We could leak an XR if we created an XR
// then crashed before saving a reference to it. We'd create another XR on
// the next reconcile.
cm.SetResourceReference(meta.ReferenceTo(xr, xr.GetObjectKind().GroupVersionKind()))
if err := s.client.Update(ctx, cm); err != nil {
return errors.Wrap(err, errUpdateClaim)
}
// Apply the XR, unless it's a no-op change.
err := s.client.Apply(ctx, xr, resource.AllowUpdateIf(func(old, obj runtime.Object) bool { return !cmp.Equal(old, obj) }))
if err := resource.Ignore(resource.IsNotAllowed, err); err != nil {
return errors.Wrap(err, errApplyComposite)
}
// Below this point we're syncing XR status -> claim status.
// Merge the XR's status into the claim's status.
if err := merge(cm.Object["status"], xr.Object["status"],
// XR status fields overwrite non-empty claim fields.
withMergeOptions(mergo.WithOverride),
// Don't sync XR machinery (i.e. status conditions, connection details).
withSrcFilter(xcrd.GetPropFields(xcrd.CompositeResourceStatusProps())...)); err != nil {
return errors.Wrap(err, errMergeClaimStatus)
}
if err := s.client.Status().Update(ctx, cm); err != nil {
return errors.Wrap(err, errUpdateClaimStatus)
}
// Propagate the actual external name back from the XR to the claim if it's
// set. The name we're propagating here will may be a name the XR must
// enforce (i.e. overriding any requested by the claim) but will often
// actually just be propagating back a name that was already propagated
// forward from the claim to the XR earlier in this method.
if en := meta.GetExternalName(xr); en != "" {
meta.SetExternalName(cm, en)
}
// We want to propagate the XR's spec to the claim's spec, but first we must
// filter out any well-known fields that are unique to XR. We do this by:
// 1. Grabbing a map whose keys represent all well-known XR fields.
// 2. Deleting any well-known fields that we want to propagate.
// 3. Filtering OUT the remaining map keys from the XR's spec so that we end
// up adding only the well-known fields to the claim's spec.
wellKnownXRFields := xcrd.CompositeResourceSpecProps()
for _, field := range xcrd.PropagateSpecProps {
delete(wellKnownXRFields, field)
}
// CompositionRevisionRef is a special field which needs to be propagated
// based on the Update policy. If the policy is `Automatic`, we need to
// overwrite the claim's value with the XR's which should be the
// `currentRevision`
if xr.GetCompositionUpdatePolicy() != nil && *xr.GetCompositionUpdatePolicy() == xpv1.UpdateAutomatic {
cm.SetCompositionRevisionReference(xr.GetCompositionRevisionReference())
}
// Propagate the XR's spec (minus well known fields) to the claim's spec.
if err := merge(cm.Object["spec"], xr.Object["spec"],
withSrcFilter(xcrd.GetPropFields(wellKnownXRFields)...)); err != nil {
return errors.Wrap(err, errMergeClaimSpec)
}
return errors.Wrap(s.client.Update(ctx, cm), errUpdateClaim)
}

View File

@ -0,0 +1,441 @@
/*
Copyright 2024 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/crossplane/crossplane/internal/names"
"github.com/crossplane/crossplane/internal/xcrd"
)
func TestClientSideSync(t *testing.T) {
errBoom := errors.New("boom")
type params struct {
c client.Client
ng names.NameGenerator
}
type args struct {
ctx context.Context
cm *claim.Unstructured
xr *composite.Unstructured
}
type want struct {
cm *claim.Unstructured
xr *composite.Unstructured
err error
}
cases := map[string]struct {
reason string
params params
args args
want want
}{
"RefuseToBindBoundXR": {
reason: "We should return an error if the XR is already bound to a different claim.",
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetName("cool-claim")
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetClaimReference(&claim.Reference{Name: "another-claim"})
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetName("cool-claim")
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetClaimReference(&claim.Reference{Name: "another-claim"})
}),
err: errors.New(errBindCompositeConflict),
},
},
"WeirdClaimSpec": {
reason: "We should return an error if the claim spec is not an object.",
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.Object["spec"] = 42
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.Object["spec"] = 42
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
}),
err: errors.New(errUnsupportedClaimSpec),
},
},
"GenerateXRNameError": {
reason: "We should return an error if we can't generate an XR name.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return errBoom
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
err: errors.Wrap(errBoom, errGenerateName),
},
},
"UpdateClaimResourceRefError": {
reason: "We should return an error if we can't update the claim to persist its resourceRef.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
cd.SetName("cool-claim-random")
return nil
}),
c: &test.MockClient{
MockUpdate: test.NewMockUpdateFn(errBoom),
},
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
cm.SetResourceReference(&corev1.ObjectReference{
Name: "cool-claim-random",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetName("cool-claim-random")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
err: errors.Wrap(errBoom, errUpdateClaim),
},
},
"ApplyXRError": {
reason: "We should return an error if we can't apply the XR.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
cd.SetName("cool-claim-random")
return nil
}),
c: &test.MockClient{
// Updating the claim should succeed.
MockUpdate: test.NewMockUpdateFn(nil),
// Applying the XR will first call Get, which should fail.
MockGet: test.NewMockGetFn(errBoom),
},
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
cm.SetResourceReference(&corev1.ObjectReference{
Name: "cool-claim-random",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetName("cool-claim-random")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
err: errors.Wrap(errors.Wrap(errBoom, "cannot get object"), errApplyComposite),
},
},
"UpdateClaimStatusError": {
reason: "We should return an error if we can't update the claim's status.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
cd.SetName("cool-claim-random")
return nil
}),
c: &test.MockClient{
// Updating the claim should succeed.
MockUpdate: test.NewMockUpdateFn(nil),
// Applying the XR will call get.
MockGet: test.NewMockGetFn(nil),
// Updating the claim's status should fail.
MockStatusUpdate: test.NewMockSubResourceUpdateFn(errBoom),
},
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
cm.SetResourceReference(&corev1.ObjectReference{
Name: "cool-claim-random",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetName("cool-claim-random")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
}),
err: errors.Wrap(errBoom, errUpdateClaimStatus),
},
},
"XRDoesNotExist": {
reason: "We should create, bind, and sync with an XR when none exists.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
cd.SetName("cool-claim-random")
return nil
}),
c: &test.MockClient{
// Updating the claim the first time to set the resourceRef
// should succeed. The second time we update it should fail.
MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error {
if obj.(*claim.Unstructured).GetCompositionSelector() != nil {
return errBoom
}
return nil
}),
// Applying the XR will call get.
MockGet: test.NewMockGetFn(nil),
// Updating the claim's status should succeed.
MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
},
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
meta.SetExternalName(cm, "external-name")
// Kube stuff should not be propagated to the XR.
cm.SetLabels(map[string]string{
"k8s.io/some-label": "filter-me-out",
})
cm.SetAnnotations(map[string]string{
"kubernetes.io/some-anno": "filter-me-out",
"example.org/propagate-me": "true",
})
// To make sure the claim spec is an object.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
// A user-defined spec field we should propagate.
cm.Object["spec"].(map[string]any)["propagateMe"] = "true"
}),
// A non-existent, empty XR.
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
meta.SetExternalName(cm, "external-name")
cm.SetLabels(map[string]string{
"k8s.io/some-label": "filter-me-out",
})
cm.SetAnnotations(map[string]string{
"kubernetes.io/some-anno": "filter-me-out",
"example.org/propagate-me": "true",
})
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
cm.SetResourceReference(&corev1.ObjectReference{
Name: "cool-claim-random",
})
cm.Object["spec"].(map[string]any)["propagateMe"] = "true"
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetName("cool-claim-random")
meta.SetExternalName(xr, "external-name")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetAnnotations(map[string]string{
"example.org/propagate-me": "true",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "some-composition",
})
xr.Object["spec"].(map[string]any)["propagateMe"] = "true"
}),
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
s := NewClientSideCompositeSyncer(tc.params.c, tc.params.ng)
err := s.Sync(tc.args.ctx, tc.args.cm, tc.args.xr)
if diff := cmp.Diff(tc.want.cm, tc.args.cm); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want, +got:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.xr, tc.args.xr); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want, +got:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
type CompositeModifier func(xr *composite.Unstructured)
func NewComposite(m ...CompositeModifier) *composite.Unstructured {
xr := composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{}))
for _, fn := range m {
fn(xr)
}
return xr
}

View File

@ -0,0 +1,300 @@
/*
Copyright 2024 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"fmt"
"github.com/google/go-cmp/cmp"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/util/csaupgrade"
"sigs.k8s.io/controller-runtime/pkg/client"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
"github.com/crossplane/crossplane/internal/names"
"github.com/crossplane/crossplane/internal/xcrd"
)
// Error strings
const (
errCreatePatch = "cannot create patch"
errPatchFieldManagers = "cannot patch field managers"
errUnsupportedCompositeStatus = "composite resource status was not an object"
)
// Server-side-apply field owners.
const (
// FieldOwnerXR owns the fields this controller mutates on composite
// resources (XRs).
FieldOwnerXR = "apiextensions.crossplane.io/claim"
)
// A NopManagedFieldsUpgrader does nothing.
type NopManagedFieldsUpgrader struct{}
// Upgrade does nothing.
func (u *NopManagedFieldsUpgrader) Upgrade(_ context.Context, _ client.Object, _ string, _ ...string) error {
return nil
}
// A PatchingManagedFieldsUpgrader uses a JSON patch to upgrade an object's
// managed fields from client-side to server-side apply. The upgrade is a no-op
// if the object does not need upgrading.
type PatchingManagedFieldsUpgrader struct {
client client.Writer
}
// NewPatchingManagedFieldsUpgrader returns a ManagedFieldsUpgrader that uses a
// JSON patch to upgrade and object's managed fields from client-side to
// server-side apply.
func NewPatchingManagedFieldsUpgrader(w client.Writer) *PatchingManagedFieldsUpgrader {
return &PatchingManagedFieldsUpgrader{client: w}
}
// Upgrade the supplied object's field managers from client-side to server-side
// apply.
func (u *PatchingManagedFieldsUpgrader) Upgrade(ctx context.Context, obj client.Object, ssaManager string, csaManagers ...string) error {
// UpgradeManagedFieldsPatch removes or replaces the specified CSA managers.
// Unfortunately most Crossplane controllers use CSA manager "crossplane".
// So we could for example fight with the XR controller:
//
// 1. We remove CSA manager "crossplane", triggering XR controller watch
// 2. XR controller uses CSA manager "crossplane", triggering our watch
// 3. Back to step 1 :)
//
// In practice we only need to upgrade once, to ensure we don't share fields
// that only this controller has ever applied with "crossplane". We assume
// that if our SSA manager already exists, we've done the upgrade.
for _, e := range obj.GetManagedFields() {
if e.Manager == ssaManager {
return nil
}
}
p, err := csaupgrade.UpgradeManagedFieldsPatch(obj, sets.New[string](csaManagers...), ssaManager)
if err != nil {
return errors.Wrap(err, errCreatePatch)
}
if p == nil {
// No patch means there's nothing to upgrade.
return nil
}
return errors.Wrap(resource.IgnoreNotFound(u.client.Patch(ctx, obj, client.RawPatch(types.JSONPatchType, p))), errPatchFieldManagers)
}
// A ServerSideCompositeSyncer binds and syncs a claim with a composite resource
// (XR). It uses server-side apply to update the XR.
type ServerSideCompositeSyncer struct {
client client.Client
names names.NameGenerator
}
// NewServerSideCompositeSyncer returns a CompositeSyncer that uses server-side
// apply to sync a claim with a composite resource.
func NewServerSideCompositeSyncer(c client.Client, ng names.NameGenerator) *ServerSideCompositeSyncer {
return &ServerSideCompositeSyncer{client: c, names: ng}
}
// Sync the supplied claim with the supplied composite resource (XR). Syncing
// may involve creating and binding the XR.
func (s *ServerSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstructured, xr *composite.Unstructured) error { //nolint:gocyclo // This complex process seems easier to follow in one long method.
// Refuse to bind and sync an XR bound to a different claim.
existing := xr.GetClaimReference()
proposed := cm.GetReference()
if existing != nil && !cmp.Equal(existing, proposed) {
return errors.New(errBindCompositeConflict)
}
// First we sync claim -> XR.
// Create an empty XR patch object. We'll use this object to ensure we only
// SSA our desired state, not the state we previously read from the API
// server.
xrPatch := composite.New(composite.WithGroupVersionKind(xr.GroupVersionKind()))
// If the claim references an XR, make sure we're going to apply that XR. We
// do this instead of using the supplied XR's name just in case the XR
// exists, but we couldn't get it due to a stale cache.
if ref := cm.GetResourceReference(); ref != nil {
xrPatch.SetName(ref.Name)
}
// If the XR doesn't have a name (i.e. doesn't exist), derive a name from
// the claim. The generated name is likely (but not guaranteed) to be
// available when we create the XR. If taken, then we are going to update an
// existing XR, probably hijacking it from another claim.
if xrPatch.GetName() == "" {
xrPatch.SetGenerateName(fmt.Sprintf("%s-", cm.GetName()))
if err := s.names.GenerateName(ctx, xrPatch); err != nil {
return errors.Wrap(err, errGenerateName)
}
}
// It's possible we're being asked to configure a statically provisioned XR.
// We should respect its existing external name annotation.
en := meta.GetExternalName(xr)
// Do not propagate *.kubernetes.io annotations/labels from claim to XR. For
// example kubectl.kubernetes.io/last-applied-configuration should not be
// propagated.
// See https://kubernetes.io/docs/reference/labels-annotations-taints/
// for all annotations and their semantic
if ann := withoutReservedK8sEntries(cm.GetAnnotations()); len(ann) > 0 {
meta.AddAnnotations(xrPatch, withoutReservedK8sEntries(cm.GetAnnotations()))
}
meta.AddLabels(xrPatch, withoutReservedK8sEntries(cm.GetLabels()))
meta.AddLabels(xrPatch, map[string]string{
xcrd.LabelKeyClaimName: cm.GetName(),
xcrd.LabelKeyClaimNamespace: cm.GetNamespace(),
})
// Restore the XR's original external name annotation in order to ensure we
// don't try to rename anything after the fact.
if en != "" {
meta.SetExternalName(xrPatch, en)
}
// We want to propagate the claim's spec to the composite's spec, but first
// we must filter out any well-known fields that are unique to claims. We do
// this by:
// 1. Grabbing a map whose keys represent all well-known claim fields.
// 2. Deleting any well-known fields that we want to propagate.
// 3. Using the resulting map keys to filter the claim's spec.
wellKnownClaimFields := xcrd.CompositeResourceClaimSpecProps()
for _, field := range xcrd.PropagateSpecProps {
delete(wellKnownClaimFields, field)
}
// Propagate composition revision ref from the claim if the update policy is
// manual. When the update policy is manual the claim controller is
// authoritative for this field. See below for the automatic case.
if xr.GetCompositionUpdatePolicy() != nil && *xr.GetCompositionUpdatePolicy() == xpv1.UpdateManual {
delete(wellKnownClaimFields, xcrd.CompositionRevisionRef)
}
cmSpec, ok := cm.Object["spec"].(map[string]any)
if !ok {
return errors.New(errUnsupportedClaimSpec)
}
// Propagate the claim's spec (minus well known fields) to the XR's spec.
xrPatch.Object["spec"] = withoutKeys(cmSpec, xcrd.GetPropFields(wellKnownClaimFields)...)
// We overwrite the entire XR spec above, so we wait until this point to set
// the claim reference.
xrPatch.SetClaimReference(proposed)
// Below this point we're syncing XR -> claim.
// Bind the claim to the XR. If this is a new XR it's important that we
// apply the claim before we create it. This ensures we don't leak an XR. We
// could leak an XR if we created an XR then crashed before saving a
// reference to it. We'd create another XR on the next reconcile.
cm.SetResourceReference(meta.ReferenceTo(xrPatch, xrPatch.GroupVersionKind()))
// Propagate the actual external name back from the composite to the
// claim if it's set. The name we're propagating here will may be a name
// the XR must enforce (i.e. overriding any requested by the claim) but
// will often actually just be propagating back a name that was already
// propagated forward from the claim to the XR during the
// preceding configure phase.
if en := meta.GetExternalName(xr); en != "" {
meta.SetExternalName(cm, en)
}
// Propagate composition ref from the XR if the claim doesn't have an
// opinion. Composition and revision selectors only propagate from claim ->
// XR. When a claim has selectors **and no reference** the flow should be:
//
// 1. Claim controller propagates selectors claim -> XR.
// 2. XR controller uses selectors to set XR's composition ref.
// 3. Claim controller propagates ref XR -> claim.
//
// When a claim sets a composition ref, it supercedes selectors. It should
// only be propagated claim -> XR.
if ref := xr.GetCompositionReference(); ref != nil && cm.GetCompositionReference() == nil {
cm.SetCompositionReference(ref)
}
// Propagate composition revision ref from the XR if the update policy is
// automatic. When the update policy is automatic the XR controller is
// authoritative for this field. It will update the XR's ref as new
// revisions become available, and we want to propgate the ref XR -> claim.
if p := xr.GetCompositionUpdatePolicy(); p != nil && *p == xpv1.UpdateAutomatic && xr.GetCompositionRevisionReference() != nil {
cm.SetCompositionRevisionReference(xr.GetCompositionRevisionReference())
}
// It's important that we update the claim before we apply the XR, to make
// sure the claim's resourceRef points to the XR before we create the XR.
// Otherwise we risk leaking an XR.
//
// It's also important that the API server will reject this update if we're
// reconciling an old claim, e.g. due to a stale cache. It's possible that
// we're seeing an old version without a resourceRef set, but in reality an
// XR has already been created. We don't want to leak it and create another.
if err := s.client.Update(ctx, cm); err != nil {
return errors.Wrap(err, errUpdateClaim)
}
if err := s.client.Patch(ctx, xrPatch, client.Apply, client.ForceOwnership, client.FieldOwner(FieldOwnerXR)); err != nil {
return errors.Wrap(err, errApplyComposite)
}
// Update the XR passed to this method to reflect the state returned by the
// API server when we patched it.
xr.Object = xrPatch.Object
m, ok := xr.Object["status"]
if !ok {
// If the XR doesn't have a status yet there's nothing else to sync.
// Just update the claim passed to this method to reflect the state
// returned by the API server when we patched it.
return nil
}
xrStatus, ok := m.(map[string]any)
if !ok {
return errors.New(errUnsupportedCompositeStatus)
}
// Preserve Crossplane machinery, like status conditions.
synced := cm.GetCondition(xpv1.TypeSynced)
ready := cm.GetCondition(xpv1.TypeReady)
pub := cm.GetConnectionDetailsLastPublishedTime()
// Update the claim's user-defined status fields to match the XRs.
cm.Object["status"] = withoutKeys(xrStatus, xcrd.GetPropFields(xcrd.CompositeResourceStatusProps())...)
if !synced.Equal(xpv1.Condition{}) {
cm.SetConditions(synced)
}
if !ready.Equal(xpv1.Condition{}) {
cm.SetConditions(ready)
}
if pub != nil {
cm.SetConnectionDetailsLastPublishedTime(pub)
}
return errors.Wrap(s.client.Status().Update(ctx, cm), errUpdateClaimStatus)
}

View File

@ -0,0 +1,467 @@
/*
Copyright 2024 The Crossplane Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package claim
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/crossplane/crossplane/internal/names"
"github.com/crossplane/crossplane/internal/xcrd"
)
func TestServerSideSync(t *testing.T) {
errBoom := errors.New("boom")
now := metav1.Now()
type params struct {
c client.Client
ng names.NameGenerator
}
type args struct {
ctx context.Context
cm *claim.Unstructured
xr *composite.Unstructured
}
type want struct {
cm *claim.Unstructured
xr *composite.Unstructured
err error
}
cases := map[string]struct {
reason string
params params
args args
want want
}{
"RefuseToBindBoundXR": {
reason: "We should return an error if the XR is already bound to a different claim.",
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetName("cool-claim")
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetClaimReference(&claim.Reference{Name: "another-claim"})
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetName("cool-claim")
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetClaimReference(&claim.Reference{Name: "another-claim"})
}),
err: errors.New(errBindCompositeConflict),
},
},
"GenerateXRNameError": {
reason: "We should return an error if we can't generate an XR name.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return errBoom
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
}),
xr: NewComposite(),
err: errors.Wrap(errBoom, errGenerateName),
},
},
"WeirdClaimSpec": {
reason: "We should return an error if the claim spec is not an object.",
params: params{
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.Object["spec"] = 42
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.Object["spec"] = 42
}),
xr: NewComposite(),
err: errors.New(errUnsupportedClaimSpec),
},
},
"UpdateClaimError": {
reason: "We should return an error if we can't update the claim.",
params: params{
c: &test.MockClient{
// Fail to update the claim.
MockUpdate: test.NewMockUpdateFn(errBoom),
},
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
// Back-propagated from the XR.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
err: errors.Wrap(errBoom, errUpdateClaim),
},
},
"ApplyXRError": {
reason: "We should return an error if we can't apply (i.e. patch) the XR.",
params: params{
c: &test.MockClient{
// Update the claim.
MockUpdate: test.NewMockUpdateFn(nil),
// Fail to patch the XR.
MockPatch: test.NewMockPatchFn(errBoom),
},
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
// Back-propagated from the XR.
cm.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetCompositionReference(&corev1.ObjectReference{
Name: "cool-composition",
})
}),
err: errors.Wrap(errBoom, errApplyComposite),
},
},
"WeirdXRStatus": {
reason: "We should return an error if the XR status is not an object.",
params: params{
c: &test.MockClient{
// Update the claim.
MockUpdate: test.NewMockUpdateFn(nil),
// Patch the XR. We reset the XR passed to Sync to the
// result of this patch, so we need to make its status
// something other than an object here.
MockPatch: test.NewMockPatchFn(nil, func(obj client.Object) error {
obj.(*composite.Unstructured).Object["status"] = 42
return nil
}),
},
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.Object["status"] = 42
}),
err: errors.New(errUnsupportedCompositeStatus),
},
},
"UpdateClaimStatusError": {
reason: "We should return an error if we can't update the claim's status.",
params: params{
c: &test.MockClient{
// Update the claim.
MockUpdate: test.NewMockUpdateFn(nil),
// Patch the XR. Make sure it has a valid status.
MockPatch: test.NewMockPatchFn(nil, func(obj client.Object) error {
obj.(*composite.Unstructured).SetConditions(xpv1.Creating())
return nil
}),
// Fail to update the claim's status.
MockStatusUpdate: test.NewMockSubResourceUpdateFn(errBoom),
},
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
// To make sure the claim spec is an object.
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
}),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
cm.SetResourceReference(&corev1.ObjectReference{
Name: "existing-composite",
})
cm.Object["status"] = map[string]any{}
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetName("existing-composite")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.SetConditions(xpv1.Creating())
}),
err: errors.Wrap(errBoom, errUpdateClaimStatus),
},
},
"XRDoesNotExist": {
reason: "We should create, bind, and sync with an XR when none exists.",
params: params{
c: &test.MockClient{
// Update the claim.
MockUpdate: test.NewMockUpdateFn(nil),
// Patch the XR. Make sure it has a valid status.
MockPatch: test.NewMockPatchFn(nil, func(obj client.Object) error {
obj.(*composite.Unstructured).Object["status"] = map[string]any{
"userDefinedField": "status",
}
return nil
}),
// Update the claim's status.
MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
},
ng: names.NameGeneratorFn(func(ctx context.Context, cd resource.Object) error {
// Generate a name for the XR.
cd.SetName("cool-claim-random")
return nil
}),
},
args: args{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
meta.SetExternalName(cm, "external-name")
// Kube stuff should not be propagated to the XR.
cm.SetLabels(map[string]string{
"k8s.io/some-label": "filter-me-out",
})
cm.SetAnnotations(map[string]string{
"kubernetes.io/some-anno": "filter-me-out",
"example.org/propagate-me": "true",
})
// Make sure user-defined fields are propagated to the XR.
cm.Object["spec"] = map[string]any{
"userDefinedField": "spec",
}
// Make sure these don't get lost when we propagate status
// from the XR.
cm.SetConditions(xpv1.ReconcileSuccess(), Waiting())
cm.SetConnectionDetailsLastPublishedTime(&now)
}),
xr: NewComposite(),
},
want: want{
cm: NewClaim(func(cm *claim.Unstructured) {
cm.SetNamespace("default")
cm.SetName("cool-claim")
meta.SetExternalName(cm, "external-name")
cm.SetLabels(map[string]string{
"k8s.io/some-label": "filter-me-out",
})
cm.SetAnnotations(map[string]string{
"kubernetes.io/some-anno": "filter-me-out",
"example.org/propagate-me": "true",
})
cm.Object["spec"] = map[string]any{
"userDefinedField": "spec",
}
cm.SetResourceReference(&corev1.ObjectReference{
Name: "cool-claim-random",
})
cm.Object["status"] = map[string]any{
"userDefinedField": "status",
}
cm.SetConditions(xpv1.ReconcileSuccess(), Waiting())
cm.SetConnectionDetailsLastPublishedTime(&now)
}),
xr: NewComposite(func(xr *composite.Unstructured) {
xr.SetGenerateName("cool-claim-")
xr.SetName("cool-claim-random")
meta.SetExternalName(xr, "external-name")
xr.SetLabels(map[string]string{
xcrd.LabelKeyClaimNamespace: "default",
xcrd.LabelKeyClaimName: "cool-claim",
})
xr.SetAnnotations(map[string]string{
"example.org/propagate-me": "true",
})
xr.Object["spec"] = map[string]any{
"userDefinedField": "spec",
}
xr.SetClaimReference(&claim.Reference{
Namespace: "default",
Name: "cool-claim",
})
xr.Object["status"] = map[string]any{
"userDefinedField": "status",
}
}),
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
s := NewServerSideCompositeSyncer(tc.params.c, tc.params.ng)
err := s.Sync(tc.args.ctx, tc.args.cm, tc.args.xr)
if diff := cmp.Diff(tc.want.cm, tc.args.cm); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want, +got:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.xr, tc.args.xr); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want, +got:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\ns.Sync(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}

View File

@ -32,7 +32,7 @@ const (
var CompositionRevisionRef = "compositionRevisionRef"
// PropagateSpecProps is the list of XRC spec properties to propagate
// when translating an XRC into an XR and vice-versa.
// when translating an XRC into an XR.
var PropagateSpecProps = []string{"compositionRef", "compositionSelector", "compositionUpdatePolicy", "compositionRevisionSelector"}
// TODO(negz): Add descriptions to schema fields.