diff --git a/internal/controller/apiresourceschema/controller.go b/internal/controller/apiresourceschema/controller.go index a7f09a3..e068e3f 100644 --- a/internal/controller/apiresourceschema/controller.go +++ b/internal/controller/apiresourceschema/controller.go @@ -18,9 +18,6 @@ package apiresourceschema import ( "context" - "crypto/sha1" - "encoding/hex" - "encoding/json" "fmt" "reflect" "strings" @@ -29,6 +26,7 @@ import ( "go.uber.org/zap" "github.com/kcp-dev/api-syncagent/internal/controllerutil/predicate" + "github.com/kcp-dev/api-syncagent/internal/crypto" "github.com/kcp-dev/api-syncagent/internal/discovery" "github.com/kcp-dev/api-syncagent/internal/projection" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" @@ -255,15 +253,7 @@ func (r *Reconciler) applyProjection(apiGroup string, crd *apiextensionsv1.Custo // getAPIResourceSchemaName generates the name for the ARS in kcp. Note that // kcp requires, just like CRDs, that ARS are named following a specific pattern. func (r *Reconciler) getAPIResourceSchemaName(apiGroup string, crd *apiextensionsv1.CustomResourceDefinition) string { - hash := sha1.New() - if err := json.NewEncoder(hash).Encode(crd.Spec.Names); err != nil { - // This is not something that should ever happen at runtime and is also not - // something we can really gracefully handle, so crashing and restarting might - // be a good way to signal the service owner that something is up. - panic(fmt.Sprintf("Failed to hash PublishedResource source: %v", err)) - } - - checksum := hex.EncodeToString(hash.Sum(nil)) + checksum := crypto.Hash(crd.Spec.Names) // include a leading "v" to prevent SHA-1 hashes with digits to break the name return fmt.Sprintf("v%s.%s.%s", checksum[:8], crd.Spec.Names.Plural, apiGroup) diff --git a/internal/crypto/hash.go b/internal/crypto/hash.go new file mode 100644 index 0000000..b3df0d4 --- /dev/null +++ b/internal/crypto/hash.go @@ -0,0 +1,51 @@ +/* +Copyright 2025 The KCP 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 crypto + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" +) + +func Hash(data any) string { + hash := sha1.New() + + var err error + switch asserted := data.(type) { + case string: + _, err = hash.Write([]byte(asserted)) + case []byte: + _, err = hash.Write(asserted) + default: + err = json.NewEncoder(hash).Encode(data) + } + + if err != nil { + // This is not something that should ever happen at runtime and is also not + // something we can really gracefully handle, so crashing and restarting might + // be a good way to signal the service owner that something is up. + panic(fmt.Sprintf("Failed to hash: %v", err)) + } + + return hex.EncodeToString(hash.Sum(nil)) +} + +func ShortHash(data any) string { + return Hash(data)[:20] +} diff --git a/internal/projection/naming.go b/internal/projection/naming.go index 39df5a6..79e9e98 100644 --- a/internal/projection/naming.go +++ b/internal/projection/naming.go @@ -17,13 +17,12 @@ limitations under the License. package projection import ( - "crypto/sha1" - "encoding/hex" "fmt" "strings" "github.com/kcp-dev/logicalcluster/v3" + "github.com/kcp-dev/api-syncagent/internal/crypto" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -44,9 +43,9 @@ func GenerateLocalObjectName(pr *syncagentv1alpha1.PublishedResource, object met replacer := strings.NewReplacer( // order of elements is important here, "$fooHash" needs to be defined before "$foo" syncagentv1alpha1.PlaceholderRemoteClusterName, clusterName.String(), - syncagentv1alpha1.PlaceholderRemoteNamespaceHash, shortSha1Hash(object.GetNamespace()), + syncagentv1alpha1.PlaceholderRemoteNamespaceHash, crypto.ShortHash(object.GetNamespace()), syncagentv1alpha1.PlaceholderRemoteNamespace, object.GetNamespace(), - syncagentv1alpha1.PlaceholderRemoteNameHash, shortSha1Hash(object.GetName()), + syncagentv1alpha1.PlaceholderRemoteNameHash, crypto.ShortHash(object.GetName()), syncagentv1alpha1.PlaceholderRemoteName, object.GetName(), ) @@ -68,17 +67,3 @@ func GenerateLocalObjectName(pr *syncagentv1alpha1.PublishedResource, object met return result } - -func shortSha1Hash(value string) string { - hash := sha1.New() - if _, err := hash.Write([]byte(value)); err != nil { - // This is not something that should ever happen at runtime and is also not - // something we can really gracefully handle, so crashing and restarting might - // be a good way to signal the service owner that something is up. - panic(fmt.Sprintf("Failed to hash string: %v", err)) - } - - encoded := hex.EncodeToString(hash.Sum(nil)) - - return encoded[:20] -} diff --git a/internal/sync/meta.go b/internal/sync/meta.go index 381248a..69ca51d 100644 --- a/internal/sync/meta.go +++ b/internal/sync/meta.go @@ -22,6 +22,8 @@ import ( "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" + "github.com/kcp-dev/api-syncagent/internal/crypto" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -56,7 +58,7 @@ func ensureAnnotations(obj metav1.Object, desiredAnnotations map[string]string) } func ensureFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlruntimeclient.Client, obj *unstructured.Unstructured, finalizer string) (updated bool, err error) { - finalizers := sets.New[string](obj.GetFinalizers()...) + finalizers := sets.New(obj.GetFinalizers()...) if finalizers.Has(deletionFinalizer) { return false, nil } @@ -75,7 +77,7 @@ func ensureFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlrun } func removeFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlruntimeclient.Client, obj *unstructured.Unstructured, finalizer string) (updated bool, err error) { - finalizers := sets.New[string](obj.GetFinalizers()...) + finalizers := sets.New(obj.GetFinalizers()...) if !finalizers.Has(deletionFinalizer) { return false, nil } @@ -122,27 +124,32 @@ func (k objectKey) String() string { } func (k objectKey) Key() string { - result := k.Name - if k.Namespace != "" { - result = k.Namespace + "_" + result - } - if k.ClusterName != "" { - result = string(k.ClusterName) + "_" + result - } - - return result + return crypto.Hash(k) } func (k objectKey) Labels() labels.Set { - return labels.Set{ - remoteObjectClusterLabel: string(k.ClusterName), - remoteObjectNamespaceLabel: k.Namespace, - remoteObjectNameLabel: k.Name, + // Name and namespace can be more than 63 characters long, so we must hash them + // to turn them into valid label values. The full, original value is kept as an annotation. + s := labels.Set{ + remoteObjectClusterLabel: string(k.ClusterName), + remoteObjectNameHashLabel: crypto.Hash(k.Name), } + + if k.Namespace != "" { + s[remoteObjectNamespaceHashLabel] = crypto.Hash(k.Namespace) + } + + return s } func (k objectKey) Annotations() labels.Set { - s := labels.Set{} + s := labels.Set{ + remoteObjectNameAnnotation: k.Name, + } + + if k.Namespace != "" { + s[remoteObjectNamespaceAnnotation] = k.Namespace + } if !k.WorkspacePath.Empty() { s[remoteObjectWorkspacePathAnnotation] = k.WorkspacePath.String() diff --git a/internal/sync/metadata.go b/internal/sync/metadata.go index 36dcd26..fc79b99 100644 --- a/internal/sync/metadata.go +++ b/internal/sync/metadata.go @@ -17,6 +17,7 @@ limitations under the License. package sync import ( + "fmt" "maps" "strings" @@ -29,7 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ) -func stripMetadata(obj *unstructured.Unstructured) *unstructured.Unstructured { +func stripMetadata(obj *unstructured.Unstructured) error { obj.SetCreationTimestamp(metav1.Time{}) obj.SetFinalizers(nil) obj.SetGeneration(0) @@ -39,70 +40,100 @@ func stripMetadata(obj *unstructured.Unstructured) *unstructured.Unstructured { obj.SetUID("") obj.SetSelfLink("") - stripAnnotations(obj) - stripLabels(obj) + if err := stripAnnotations(obj); err != nil { + return fmt.Errorf("failed to strip annotations: %w", err) + } + if err := stripLabels(obj); err != nil { + return fmt.Errorf("failed to strip labels: %w", err) + } - return obj + return nil } -func stripAnnotations(obj *unstructured.Unstructured) *unstructured.Unstructured { - annotations := obj.GetAnnotations() - if annotations == nil { - return obj +func setNestedMapOmitempty(obj *unstructured.Unstructured, value map[string]string, path ...string) error { + if len(value) == 0 { + unstructured.RemoveNestedField(obj.Object, path...) + return nil } - delete(annotations, "kcp.io/cluster") - delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + return unstructured.SetNestedStringMap(obj.Object, value, path...) +} - maps.DeleteFunc(annotations, func(annotation string, _ string) bool { - return strings.HasPrefix(annotation, relatedObjectAnnotationPrefix) - }) +func stripAnnotations(obj *unstructured.Unstructured) error { + annotations := obj.GetAnnotations() + if annotations == nil { + return nil + } - obj.SetAnnotations(annotations) + if err := setNestedMapOmitempty(obj, filterUnsyncableAnnotations(annotations), "metadata", "annotations"); err != nil { + return err + } - return obj + return nil } -func stripLabels(obj *unstructured.Unstructured) *unstructured.Unstructured { +func stripLabels(obj *unstructured.Unstructured) error { labels := obj.GetLabels() if labels == nil { - return obj + return nil } - for _, label := range ignoredRemoteLabels.UnsortedList() { - delete(labels, label) + if err := setNestedMapOmitempty(obj, filterUnsyncableLabels(labels), "metadata", "labels"); err != nil { + return err } - obj.SetLabels(labels) - - return obj + return nil } -// ignoredRemoteLabels are labels we never want to copy from the remote to local objects. -var ignoredRemoteLabels = sets.New[string]( +// unsyncableLabels are labels we never want to copy from the remote to local objects. +var unsyncableLabels = sets.New( remoteObjectClusterLabel, - remoteObjectNamespaceLabel, - remoteObjectNameLabel, + remoteObjectNamespaceHashLabel, + remoteObjectNameHashLabel, ) -// filterRemoteLabels removes all unwanted remote labels and returns a new label set. -func filterRemoteLabels(remoteLabels labels.Set) labels.Set { - filteredLabels := labels.Set{} +// filterUnsyncableLabels removes all unwanted remote labels and returns a new label set. +func filterUnsyncableLabels(original labels.Set) labels.Set { + return filterLabels(original, unsyncableLabels) +} + +// unsyncableAnnotations are annotations we never want to copy from the remote to local objects. +var unsyncableAnnotations = sets.New( + "kcp.io/cluster", + "kubectl.kubernetes.io/last-applied-configuration", + remoteObjectNamespaceAnnotation, + remoteObjectNameAnnotation, + remoteObjectWorkspacePathAnnotation, +) - for k, v := range remoteLabels { - if !ignoredRemoteLabels.Has(k) { - filteredLabels[k] = v +// filterUnsyncableAnnotations removes all unwanted remote annotations and returns a new label set. +func filterUnsyncableAnnotations(original labels.Set) labels.Set { + filtered := filterLabels(original, unsyncableAnnotations) + + maps.DeleteFunc(filtered, func(annotation string, _ string) bool { + return strings.HasPrefix(annotation, relatedObjectAnnotationPrefix) + }) + + return filtered +} + +func filterLabels(original labels.Set, forbidList sets.Set[string]) labels.Set { + filtered := labels.Set{} + for k, v := range original { + if !forbidList.Has(k) { + filtered[k] = v } } - return filteredLabels + return filtered } func RemoteNameForLocalObject(localObj ctrlruntimeclient.Object) *reconcile.Request { labels := localObj.GetLabels() + annotations := localObj.GetAnnotations() clusterName := labels[remoteObjectClusterLabel] - namespace := labels[remoteObjectNamespaceLabel] - name := labels[remoteObjectNameLabel] + namespace := annotations[remoteObjectNamespaceAnnotation] + name := annotations[remoteObjectNameAnnotation] // reject/ignore invalid/badly labelled object if clusterName == "" || name == "" { @@ -117,3 +148,43 @@ func RemoteNameForLocalObject(localObj ctrlruntimeclient.Object) *reconcile.Requ }, } } + +// threeWayDiffMetadata is used when updating an object. Since the lastKnownState for any object +// does not contain syncer-related metadata, this function determines whether labels/annotations are +// missing by comparing the desired* sets with the current state on the destObj. +// If a label/annotation is found to be missing or wrong, this function will set it on the sourceObj. +// This is confusing at first, but the source object here is just a DeepCopy from the actual source +// object and the caller is not meant to persist changes on the source object. The reason the changes +// are performed on the source object is so that when creating the patch later on (which is done by +// comparing the source object with the lastKnownState), the patch will contain the necessary changes. +func threeWayDiffMetadata(sourceObj, destObj *unstructured.Unstructured, desiredLabels, desiredAnnotations labels.Set) { + destLabels := destObj.GetLabels() + sourceLabels := sourceObj.GetLabels() + + for label, value := range desiredLabels { + if destValue, ok := destLabels[label]; !ok || destValue != value { + if sourceLabels == nil { + sourceLabels = map[string]string{} + } + + sourceLabels[label] = value + } + } + + sourceObj.SetLabels(sourceLabels) + + destAnnotations := destObj.GetAnnotations() + sourceAnnotations := sourceObj.GetAnnotations() + + for label, value := range desiredAnnotations { + if destValue, ok := destAnnotations[label]; !ok || destValue != value { + if sourceAnnotations == nil { + sourceAnnotations = map[string]string{} + } + + sourceAnnotations[label] = value + } + } + + sourceObj.SetAnnotations(sourceAnnotations) +} diff --git a/internal/sync/object_syncer.go b/internal/sync/object_syncer.go index 480f76a..64e449d 100644 --- a/internal/sync/object_syncer.go +++ b/internal/sync/object_syncer.go @@ -51,6 +51,8 @@ type objectSyncer struct { syncStatusBack bool // whether or not to add/expect a finalizer on the source blockSourceDeletion bool + // whether or not to place sync-related metadata on the destination object + metadataOnDestination bool // optional mutations for both directions of the sync mutator mutation.Mutator // stateStore is capable of remembering the state of a Kubernetes object @@ -177,7 +179,9 @@ func (s *objectSyncer) syncObjectSpec(log *zap.SugaredLogger, source, dest syncS } sourceObjCopy := source.object.DeepCopy() - stripMetadata(sourceObjCopy) + if err = stripMetadata(sourceObjCopy); err != nil { + return false, fmt.Errorf("failed to strip metadata from source object: %w", err) + } log = log.With("dest-object", newObjectKey(dest.object, dest.clusterName, logicalcluster.None)) @@ -187,10 +191,21 @@ func (s *objectSyncer) syncObjectSpec(log *zap.SugaredLogger, source, dest syncS lastKnownSourceState.SetAPIVersion(sourceObjCopy.GetAPIVersion()) lastKnownSourceState.SetKind(sourceObjCopy.GetKind()) - // update annotations (this is important if the admin later flipped the enableWorkspacePaths - // option in the PublishedResource) - sourceKey := newObjectKey(source.object, source.clusterName, source.workspacePath) - ensureAnnotations(sourceObjCopy, sourceKey.Annotations()) + // We want to now restore/fix broken labels or annotations on the destination object. Not all + // of the sync-related metadata is relevant to _finding_ the destination object, so there is + // a chance that a user has fiddled with the metadata and would have broken some other part + // of the syncing. + // The lastKnownState is based on the source object, so just from looking at it we could not + // determine whether a label/annotation is missing/broken. However we do need to know this, + // because we later have to distinguish between empty and non-empty patches, so that we know + // when to requeue or stop syncing. So we cannot just blindly call ensureLabels() on the + // sourceObjCopy, as that could create meaningless patches. + // To achieve this, we individually check the labels/annotations on the destination object, + // which we thankfully already fetched earlier. + if s.metadataOnDestination { + sourceKey := newObjectKey(source.object, source.clusterName, source.workspacePath) + threeWayDiffMetadata(sourceObjCopy, dest.object, sourceKey.Labels(), sourceKey.Annotations()) + } // now we can diff the two versions and create a patch rawPatch, err := s.createMergePatch(lastKnownSourceState, sourceObjCopy) @@ -221,8 +236,8 @@ func (s *objectSyncer) syncObjectSpec(log *zap.SugaredLogger, source, dest syncS } // update selected metadata fields - ensureLabels(dest.object, filterRemoteLabels(sourceObjCopy.GetLabels())) - ensureAnnotations(dest.object, sourceObjCopy.GetAnnotations()) + ensureLabels(dest.object, filterUnsyncableLabels(sourceObjCopy.GetLabels())) + ensureAnnotations(dest.object, filterUnsyncableAnnotations(sourceObjCopy.GetAnnotations())) // TODO: Check if anything has changed and skip the .Update() call if source and dest // are identical w.r.t. the fields we have copied (spec, annotations, labels, ..). @@ -236,7 +251,8 @@ func (s *objectSyncer) syncObjectSpec(log *zap.SugaredLogger, source, dest syncS } if requeue { - // remember this object state for the next reconciliation + // remember this object state for the next reconciliation (this will strip any syncer-related + // metadata the 3-way diff may have added above) if err := s.stateStore.Put(sourceObjCopy, source.clusterName, s.subresources); err != nil { return true, fmt.Errorf("failed to update sync state: %w", err) } @@ -278,19 +294,20 @@ func (s *objectSyncer) ensureDestinationObject(log *zap.SugaredLogger, source, d return fmt.Errorf("failed to ensure destination namespace: %w", err) } - // remove source metadata (like UID and generation) to allow destination object creation to succeed - stripMetadata(destObj) + // remove source metadata (like UID and generation, but also labels and annotations belonging to + // the sync-agent) to allow destination object creation to succeed + if err := stripMetadata(destObj); err != nil { + return fmt.Errorf("failed to strip metadata from destination object: %w", err) + } // remember the connection between the source and destination object sourceObjKey := newObjectKey(source.object, source.clusterName, source.workspacePath) ensureLabels(destObj, sourceObjKey.Labels()) + ensureAnnotations(destObj, sourceObjKey.Annotations()) // remember what agent synced this object s.labelWithAgent(destObj) - // put optional additional annotations on the new object - ensureAnnotations(destObj, sourceObjKey.Annotations()) - // finally, we can create the destination object objectLog := log.With("dest-object", newObjectKey(destObj, dest.clusterName, logicalcluster.None)) objectLog.Debugw("Creating destination object…") @@ -333,9 +350,10 @@ func (s *objectSyncer) adoptExistingDestinationObject(log *zap.SugaredLogger, de // the destination object from another source object, which would then lead to the two source objects // "fighting" about the one destination object. ensureLabels(existingDestObj, sourceKey.Labels()) - s.labelWithAgent(existingDestObj) ensureAnnotations(existingDestObj, sourceKey.Annotations()) + s.labelWithAgent(existingDestObj) + if err := dest.client.Update(dest.ctx, existingDestObj); err != nil { return fmt.Errorf("failed to upsert current destination object labels: %w", err) } diff --git a/internal/sync/state_store.go b/internal/sync/state_store.go index bf9e048..e845b5b 100644 --- a/internal/sync/state_store.go +++ b/internal/sync/state_store.go @@ -17,14 +17,13 @@ limitations under the License. package sync import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" "fmt" "strings" "github.com/kcp-dev/logicalcluster/v3" + "github.com/kcp-dev/api-syncagent/internal/crypto" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -84,7 +83,9 @@ func (op *objectStateStore) Put(obj *unstructured.Unstructured, clusterName logi func (op *objectStateStore) snapshotObject(obj *unstructured.Unstructured, subresources []string) (string, error) { obj = obj.DeepCopy() - obj = stripMetadata(obj) + if err := stripMetadata(obj); err != nil { + return "", err + } // besides metadata, we also do not care about the object's subresources data := obj.UnstructuredContent() @@ -112,26 +113,16 @@ type kubernetesBackend struct { } func hashObject(obj *unstructured.Unstructured) string { - data := map[string]any{ + return crypto.ShortHash(map[string]any{ "apiVersion": obj.GetAPIVersion(), + "kind": obj.GetKind(), "namespace": obj.GetNamespace(), "name": obj.GetName(), - } - - hash := sha1.New() - - if err := json.NewEncoder(hash).Encode(data); err != nil { - // This is not something that should ever happen at runtime and is also not - // something we can really gracefully handle, so crashing and restarting might - // be a good way to signal the service owner that something is up. - panic(fmt.Sprintf("Failed to hash object key: %v", err)) - } - - return hex.EncodeToString(hash.Sum(nil)) + }) } func newKubernetesBackend(namespace string, primaryObject, stateCluster syncSide) *kubernetesBackend { - keyHash := hashObject(primaryObject.object) + shortKeyHash := hashObject(primaryObject.object) secretLabels := newObjectKey(primaryObject.object, primaryObject.clusterName, primaryObject.workspacePath).Labels() secretLabels[objectStateLabelName] = objectStateLabelValue @@ -139,7 +130,7 @@ func newKubernetesBackend(namespace string, primaryObject, stateCluster syncSide return &kubernetesBackend{ secretName: types.NamespacedName{ // trim hash down; 20 was chosen at random - Name: fmt.Sprintf("obj-state-%s-%s", primaryObject.clusterName, keyHash[:20]), + Name: fmt.Sprintf("obj-state-%s-%s", primaryObject.clusterName, shortKeyHash), Namespace: namespace, }, labels: secretLabels, diff --git a/internal/sync/syncer.go b/internal/sync/syncer.go index 13217c9..0b59abd 100644 --- a/internal/sync/syncer.go +++ b/internal/sync/syncer.go @@ -165,6 +165,10 @@ func (s *ResourceSyncer) Process(ctx Context, remoteObj *unstructured.Unstructur mutator: s.mutator, // make sure the syncer can remember the current state of any object stateStore: stateStore, + // For the main resource, we need to store metadata on the destination copy + // (i.e. on the service cluster), so that the original and copy are linked + // together and can be found. + metadataOnDestination: true, } requeue, err = syncer.Sync(log, sourceSide, destSide) diff --git a/internal/sync/syncer_related.go b/internal/sync/syncer_related.go index fd4155c..77e3498 100644 --- a/internal/sync/syncer_related.go +++ b/internal/sync/syncer_related.go @@ -151,6 +151,8 @@ func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateSto blockSourceDeletion: relRes.Origin == "kcp", // apply mutation rules configured for the related resource mutator: mutation.NewMutator(relRes.Mutation), + // we never want to store sync-related metadata inside kcp + metadataOnDestination: false, } requeue, err = syncer.Sync(log, sourceSide, destSide) diff --git a/internal/sync/syncer_test.go b/internal/sync/syncer_test.go index 80b46ec..0240e87 100644 --- a/internal/sync/syncer_test.go +++ b/internal/sync/syncer_test.go @@ -182,17 +182,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -209,17 +211,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -257,17 +261,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -315,17 +321,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -352,17 +360,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -379,17 +389,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Miss Scarlet", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -416,10 +428,12 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -443,17 +457,19 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -489,14 +505,14 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Annotations: map[string]string{ - "existing-annotation": "annotation-value", + remoteObjectNameAnnotation: "my-test-thing", + "existing-annotation": "annotation-value", }, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", - "existing-label": "label-value", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + "existing-label": "label-value", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -530,16 +546,16 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Annotations: map[string]string{ - "existing-annotation": "new-annotation-value", - "new-annotation": "hei-verden", + remoteObjectNameAnnotation: "my-test-thing", + "existing-annotation": "new-annotation-value", + "new-annotation": "hei-verden", }, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", - "existing-label": "new-label-value", - "new-label": "hello-world", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + "existing-label": "new-label-value", + "new-label": "hello-world", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -552,6 +568,72 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ///////////////////////////////////////////////////////////////////////////////// + { + name: "missing syncagent-related annotations should be patched on the destination object", + remoteAPIGroup: "remote.example.corp", + localCRD: loadCRD("things"), + pubRes: remoteThingPR, + performRequeues: true, + + remoteObject: newUnstructured(&dummyv1alpha1.Thing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-thing", + Finalizers: []string{ + deletionFinalizer, + }, + }, + Spec: dummyv1alpha1.ThingSpec{ + Username: "Colonel Mustard", + }, + }, withGroupKind("remote.example.corp", "RemoteThing")), + localObject: newUnstructured(&dummyv1alpha1.Thing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testcluster-my-test-thing", + Labels: map[string]string{ + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + }, + Spec: dummyv1alpha1.ThingSpec{ + Username: "Colonel Mustard", + }, + }), + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + + expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-thing", + Finalizers: []string{ + deletionFinalizer, + }, + }, + Spec: dummyv1alpha1.ThingSpec{ + Username: "Colonel Mustard", + }, + }, withGroupKind("remote.example.corp", "RemoteThing")), + expectedLocalObject: newUnstructured(&dummyv1alpha1.Thing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testcluster-my-test-thing", + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", + }, + Labels: map[string]string{ + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + }, + Spec: dummyv1alpha1.ThingSpec{ + Username: "Colonel Mustard", + }, + }), + // last state annotation is "space optimized" and so does not include the ignored labels and annotations + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + }, + + ///////////////////////////////////////////////////////////////////////////////// + { name: "local updates should be based on the last-state annotation and ignore defaulted fields", remoteAPIGroup: "remote.example.corp", @@ -574,10 +656,12 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -585,7 +669,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Address: "Hotdogstr. 13", // we assume this field was set by a local controller/webhook, unrelated to the Sync Agent }, }), - existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -602,10 +686,12 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -614,7 +700,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Address: "Hotdogstr. 13", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -648,10 +734,12 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { "prevent-instant-deletion-in-tests", }, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -680,10 +768,12 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { }, DeletionTimestamp: &nonEmptyTime, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -762,10 +852,9 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { }, DeletionTimestamp: &nonEmptyTime, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -793,10 +882,9 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { }, DeletionTimestamp: &nonEmptyTime, Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -987,10 +1075,12 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -1020,10 +1110,12 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -1033,7 +1125,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { CurrentVersion: "v1", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -1060,10 +1152,12 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -1093,10 +1187,12 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "testcluster-my-test-thing", Labels: map[string]string{ - agentNameLabel: "textor-the-doctor", - remoteObjectClusterLabel: "testcluster", - remoteObjectNamespaceLabel: "", - remoteObjectNameLabel: "my-test-thing", + agentNameLabel: "textor-the-doctor", + remoteObjectClusterLabel: "testcluster", + remoteObjectNameHashLabel: "c346c8ceb5d104cc783d09b95e8ea7032c190948", + }, + Annotations: map[string]string{ + remoteObjectNameAnnotation: "my-test-thing", }, }, Spec: dummyv1alpha1.ThingSpec{ @@ -1106,7 +1202,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { CurrentVersion: "v1", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, } diff --git a/internal/sync/types.go b/internal/sync/types.go index 076f0cf..e048ec5 100644 --- a/internal/sync/types.go +++ b/internal/sync/types.go @@ -27,9 +27,12 @@ const ( // origin remote objects. Note that the cluster *path* label is optional and // has to be enabled per PublishedResource. - remoteObjectClusterLabel = "syncagent.kcp.io/remote-object-cluster" - remoteObjectNamespaceLabel = "syncagent.kcp.io/remote-object-namespace" - remoteObjectNameLabel = "syncagent.kcp.io/remote-object-name" + remoteObjectClusterLabel = "syncagent.kcp.io/remote-object-cluster" + remoteObjectNamespaceHashLabel = "syncagent.kcp.io/remote-object-namespace-hash" + remoteObjectNameHashLabel = "syncagent.kcp.io/remote-object-name-hash" + + remoteObjectNamespaceAnnotation = "syncagent.kcp.io/remote-object-namespace" + remoteObjectNameAnnotation = "syncagent.kcp.io/remote-object-name" remoteObjectWorkspacePathAnnotation = "syncagent.kcp.io/remote-object-workspace-path" diff --git a/test/e2e/sync/primary_test.go b/test/e2e/sync/primary_test.go index af2c0c6..71425bc 100644 --- a/test/e2e/sync/primary_test.go +++ b/test/e2e/sync/primary_test.go @@ -32,6 +32,7 @@ import ( syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" "github.com/kcp-dev/api-syncagent/test/utils" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -449,3 +450,97 @@ spec: t.Fatal("Expected no ignored object to be found on the service cluster, but did.") } } + +func TestSyncingOverlyLongNames(t *testing.T) { + const ( + apiExportName = "kcp.example.com" + orgWorkspace = "sync-long-names" + ) + + ctx := context.Background() + ctrlruntime.SetLogger(logr.Discard()) + + // setup a test environment in kcp + orgKubconfig := utils.CreateOrganization(t, ctx, orgWorkspace, apiExportName) + + // start a service cluster + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ + "test/crds/crontab.yaml", + }) + + // publish Crontabs and Backups + t.Logf("Publishing CRDs…") + prCrontabs := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-crontabs", + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "example.com", + Version: "v1", + Kind: "CronTab", + }, + // These rules make finding the local object easier, but should not be used in production. + Naming: &syncagentv1alpha1.ResourceNaming{ + Name: "$remoteName", + Namespace: "synced-$remoteNamespace", + }, + }, + } + + if err := envtestClient.Create(ctx, prCrontabs); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // start the agent in the background to update the APIExport with the CronTabs API + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + + // wait until the API is available + teamCtx := kontext.WithCluster(ctx, logicalcluster.Name(fmt.Sprintf("root:%s:team-1", orgWorkspace))) + kcpClient := utils.GetKcpAdminClusterClient(t) + utils.WaitForBoundAPI(t, teamCtx, kcpClient, schema.GroupVersionResource{ + Group: apiExportName, + Version: "v1", + Resource: "crontabs", + }) + + // create a namespace and CronTab with extremely long names + namespace := &corev1.Namespace{} + namespace.Name = strings.Repeat("yadda", 3) // 250 chars in total + + if err := kcpClient.Create(teamCtx, namespace); err != nil { + t.Fatalf("Failed to create namespace in kcp: %v", err) + } + + t.Log("Creating CronTab in kcp…") + ignoredCrontab := yamlToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + name: TBD + namespace: TBD +spec: + image: ubuntu:latest +`) + ignoredCrontab.SetNamespace(namespace.Name) + ignoredCrontab.SetName(strings.Repeat("yotta", 50)) + + if err := kcpClient.Create(teamCtx, ignoredCrontab); err != nil { + t.Fatalf("Failed to create CronTab in kcp: %v", err) + } + + // wait for the agent to sync the object down into the service cluster + + t.Logf("Wait for CronTab to be synced…") + copy := &unstructured.Unstructured{} + copy.SetAPIVersion("example.com/v1") + copy.SetKind("CronTab") + + err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { + copyKey := types.NamespacedName{Namespace: fmt.Sprintf("synced-%s", namespace.Name), Name: ignoredCrontab.GetName()} + return envtestClient.Get(ctx, copyKey, copy) == nil, nil + }) + if err != nil { + t.Fatalf("Failed to wait for object to be synced down: %v", err) + } +}