From e20fb5079c8c45c35cbdb950cf29db3be9592f7b Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 25 Jun 2025 11:19:46 +0200 Subject: [PATCH 1/5] support references that return multiple items for related resources On-behalf-of: @SAP christoph.mewes@sap.com --- internal/sync/syncer_related.go | 78 +++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/internal/sync/syncer_related.go b/internal/sync/syncer_related.go index 29aa68f..143e612 100644 --- a/internal/sync/syncer_related.go +++ b/internal/sync/syncer_related.go @@ -251,27 +251,25 @@ func resolveRelatedResourceObjects(relatedOrigin, relatedDest syncSide, relRes s func resolveRelatedResourceOriginNamespaces(relatedOrigin, relatedDest syncSide, origin syncagentv1alpha1.RelatedResourceOrigin, spec syncagentv1alpha1.RelatedResourceObjectSpec) (map[string]string, error) { switch { case spec.Reference != nil: - originNamespace, err := resolveObjectReference(relatedOrigin.object, *spec.Reference) + originNamespaces, err := resolveObjectReference(relatedOrigin.object, *spec.Reference) if err != nil { return nil, err } - if originNamespace == "" { + if len(originNamespaces) == 0 { return nil, nil } - destNamespace, err := resolveObjectReference(relatedDest.object, *spec.Reference) + destNamespaces, err := resolveObjectReference(relatedDest.object, *spec.Reference) if err != nil { return nil, err } - if destNamespace == "" { - return nil, nil + if len(destNamespaces) != len(originNamespaces) { + return nil, fmt.Errorf("cannot sync related resources: found %d namespaces on the origin, but %d on the destination side", len(originNamespaces), len(destNamespaces)) } - return map[string]string{ - originNamespace: destNamespace, - }, nil + return mapSlices(originNamespaces, destNamespaces), nil case spec.Selector != nil: namespaces := &corev1.NamespaceList{} @@ -327,6 +325,22 @@ func resolveRelatedResourceOriginNamespaces(relatedOrigin, relatedDest syncSide, } } +func mapSlices(a, b []string) map[string]string { + mapping := map[string]string{} + for i, aItem := range a { + bItem := b[i] + + // ignore any origin<->dest pair where either of the sides is empty + if bItem == "" || aItem == "" { + continue + } + + mapping[aItem] = bItem + } + + return mapping +} + func resolveRelatedResourceObjectsInNamespaces(relatedOrigin, relatedDest syncSide, relRes syncagentv1alpha1.RelatedResourceSpec, spec syncagentv1alpha1.RelatedResourceObjectSpec, namespaceMap map[string]string) ([]resolvedObject, error) { result := []resolvedObject{} @@ -368,27 +382,25 @@ func resolveRelatedResourceObjectsInNamespaces(relatedOrigin, relatedDest syncSi func resolveRelatedResourceObjectsInNamespace(relatedOrigin, relatedDest syncSide, relRes syncagentv1alpha1.RelatedResourceSpec, spec syncagentv1alpha1.RelatedResourceObjectSpec, namespace string) (map[string]string, error) { switch { case spec.Reference != nil: - originName, err := resolveObjectReference(relatedOrigin.object, *spec.Reference) + originNames, err := resolveObjectReference(relatedOrigin.object, *spec.Reference) if err != nil { return nil, err } - if originName == "" { + if len(originNames) == 0 { return nil, nil } - destName, err := resolveObjectReference(relatedDest.object, *spec.Reference) + destNames, err := resolveObjectReference(relatedDest.object, *spec.Reference) if err != nil { return nil, err } - if destName == "" { - return nil, nil + if len(destNames) != len(originNames) { + return nil, fmt.Errorf("cannot sync related resources: found %d names on the origin, but %d on the destination side", len(originNames), len(destNames)) } - return map[string]string{ - originName: destName, - }, nil + return mapSlices(originNames, destNames), nil case spec.Selector != nil: originObjects := &unstructured.UnstructuredList{} @@ -447,34 +459,44 @@ func resolveRelatedResourceObjectsInNamespace(relatedOrigin, relatedDest syncSid } } -func resolveObjectReference(object *unstructured.Unstructured, ref syncagentv1alpha1.RelatedResourceObjectReference) (string, error) { +func resolveObjectReference(object *unstructured.Unstructured, ref syncagentv1alpha1.RelatedResourceObjectReference) ([]string, error) { data, err := object.MarshalJSON() if err != nil { - return "", err + return nil, err } return resolveReference(data, ref) } -func resolveReference(jsonData []byte, ref syncagentv1alpha1.RelatedResourceObjectReference) (string, error) { - gval := gjson.Get(string(jsonData), ref.Path) - if !gval.Exists() { - return "", nil +func resolveReference(jsonData []byte, ref syncagentv1alpha1.RelatedResourceObjectReference) ([]string, error) { + result := gjson.Get(string(jsonData), ref.Path) + if !result.Exists() { + return nil, nil } - // this does apply some coalescing, like turning numbers into strings - strVal := gval.String() + var values []string + if result.IsArray() { + for _, elem := range result.Array() { + values = append(values, strings.TrimSpace(elem.String())) + } + } else { + values = append(values, strings.TrimSpace(result.String())) + } if re := ref.Regex; re != nil { var err error - strVal, err = applyRegularExpression(strVal, *re) - if err != nil { - return "", err + for i, value := range values { + value, err = applyRegularExpression(value, *re) + if err != nil { + return nil, err + } + + values[i] = value } } - return strVal, nil + return values, nil } // applyTemplate is used after a label selector has been applied and a list of namespaces or objects From 3f2584abdb4905a0386d7b691c76cbaf1f4c4e8d Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Wed, 25 Jun 2025 11:25:17 +0200 Subject: [PATCH 2/5] add array field to dummy CRD On-behalf-of: @SAP christoph.mewes@sap.com --- test/crds/backup.go | 9 +++++++-- test/crds/backup.yaml | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/test/crds/backup.go b/test/crds/backup.go index 2087547..a05bea6 100644 --- a/test/crds/backup.go +++ b/test/crds/backup.go @@ -28,6 +28,11 @@ type Backup struct { } type BackupSpec struct { - Source string `json:"source"` - Destination string `json:"destination"` + Source string `json:"source"` + Destination string `json:"destination"` + Items []BackupItem `json:"items,omitempty"` +} + +type BackupItem struct { + Name string `json:"name"` } diff --git a/test/crds/backup.yaml b/test/crds/backup.yaml index 8de6059..479ded1 100644 --- a/test/crds/backup.yaml +++ b/test/crds/backup.yaml @@ -25,3 +25,10 @@ spec: type: string destination: type: string + items: + type: array + items: + type: object + properties: + name: + type: string From 85ac771ef777a7414eb06b0bffe49a975d91a11d Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Fri, 27 Jun 2025 10:32:06 +0200 Subject: [PATCH 3/5] add new tests On-behalf-of: @SAP christoph.mewes@sap.com --- test/e2e/sync/related_test.go | 420 +++++++++++++++++++++++++++++++--- 1 file changed, 390 insertions(+), 30 deletions(-) diff --git a/test/e2e/sync/related_test.go b/test/e2e/sync/related_test.go index fe59a34..71be307 100644 --- a/test/e2e/sync/related_test.go +++ b/test/e2e/sync/related_test.go @@ -20,6 +20,7 @@ package sync import ( "context" + "errors" "fmt" "maps" "strings" @@ -52,7 +53,7 @@ func TestSyncRelatedObjects(t *testing.T) { testcases := []struct { // the name of this testcase name string - //the org workspace everything should happen in + // the org workspace everything should happen in workspace logicalcluster.Name // the configuration for the related resource relatedConfig syncagentv1alpha1.RelatedResourceSpec @@ -86,7 +87,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "service", + Origin: syncagentv1alpha1.RelatedResourceOriginService, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -137,7 +138,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "kcp", + Origin: syncagentv1alpha1.RelatedResourceOriginKcp, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -188,7 +189,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "service", + Origin: syncagentv1alpha1.RelatedResourceOriginService, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -243,7 +244,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "kcp", + Origin: syncagentv1alpha1.RelatedResourceOriginKcp, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -298,7 +299,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "service", + Origin: syncagentv1alpha1.RelatedResourceOriginService, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -353,7 +354,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "service", + Origin: syncagentv1alpha1.RelatedResourceOriginService, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -419,7 +420,7 @@ func TestSyncRelatedObjects(t *testing.T) { }, relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", - Origin: "service", + Origin: syncagentv1alpha1.RelatedResourceOriginService, Kind: "Secret", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ @@ -543,7 +544,7 @@ func TestSyncRelatedObjects(t *testing.T) { destClient := kcpClient destContext := teamCtx - if testcase.relatedConfig.Origin == "kcp" { + if testcase.relatedConfig.Origin == syncagentv1alpha1.RelatedResourceOriginKcp { originClient, destClient = destClient, originClient originContext, destContext = destContext, originContext } @@ -556,34 +557,17 @@ func TestSyncRelatedObjects(t *testing.T) { // wait for the agent to do its magic t.Log("Wait for Secret to be synced…") - copySecret := &corev1.Secret{} + copySecret := corev1.Secret{} err := wait.PollUntilContextTimeout(destContext, 500*time.Millisecond, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { copyKey := ctrlruntimeclient.ObjectKeyFromObject(&testcase.expectedSyncedRelatedObject) - return destClient.Get(ctx, copyKey, copySecret) == nil, nil + return destClient.Get(ctx, copyKey, ©Secret) == nil, nil }) if err != nil { t.Fatalf("Failed to wait for Secret to be synced: %v", err) } - // ensure the secret in kcp does not have any sync-related metadata - maps.DeleteFunc(copySecret.Labels, func(k, v string) bool { - return strings.HasPrefix(k, "claimed.internal.apis.kcp.io/") - }) - - delete(copySecret.Annotations, "kcp.io/cluster") - if len(copySecret.Annotations) == 0 { - copySecret.Annotations = nil - } - - orig := testcase.expectedSyncedRelatedObject - copySecret.CreationTimestamp = orig.CreationTimestamp - copySecret.Generation = orig.Generation - copySecret.ResourceVersion = orig.ResourceVersion - copySecret.ManagedFields = orig.ManagedFields - copySecret.UID = orig.UID - - if changes := diff.ObjectDiff(orig, copySecret); changes != "" { - t.Errorf("Synced secret does not match expected Secret:\n%s", changes) + if err := compareSecrets(copySecret, testcase.expectedSyncedRelatedObject); err != nil { + t.Fatalf("Synced secret does not match expected Secret:\n%v", err) } }) } @@ -599,3 +583,379 @@ func ensureNamespace(t *testing.T, ctx context.Context, client ctrlruntimeclient } } } + +// TestSyncRelatedMultiObjects is similar to TestSyncRelatedObjects, but here +// we test for cases where a single related resource configuration matches multiple +// Kubernetes objects. +func TestSyncRelatedMultiObjects(t *testing.T) { + const apiExportName = "kcp.example.com" + + ctrlruntime.SetLogger(logr.Discard()) + + testcases := []struct { + // the name of this testcase + name string + // the org workspace everything should happen in + workspace logicalcluster.Name + // the configuration for the related resource + relatedConfig syncagentv1alpha1.RelatedResourceSpec + // the primary object created by the user in kcp + remoteMainResource crds.Backup + // the primary object created (and potentially mutated) by the agent on the + // local cluster (we explicitly create it here to simulate that remote and + // local objects are different) + localMainResource crds.Backup + // the original related objects (will automatically be created on either the + // kcp or service side, depending on the relatedConfig above) + sourceRelatedObjects []corev1.Secret + // expectation: this is how the copies of the related objects should look + // like after the sync has completed + expectedSyncedRelatedObjects []corev1.Secret + }{ + { + name: "reference that returns a nice, sensible array", + workspace: "sensible-multi-reference", + remoteMainResource: crds.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "default", + }, + Spec: crds.BackupSpec{ + Items: []crds.BackupItem{ + {Name: "secret-1"}, + {Name: "secret-2"}, + {Name: "secret-3"}, + }, + }, + }, + localMainResource: crds.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "synced-default", + }, + Spec: crds.BackupSpec{ + Items: []crds.BackupItem{ + {Name: "mutated-secret-1"}, + {Name: "mutated-secret-2"}, + {Name: "mutated-secret-3"}, + }, + }, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "credentials", + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "Secret", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.items.#.name", + }, + }, + }, + }, + sourceRelatedObjects: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mutated-secret-1", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter1"), + }, + Type: corev1.SecretTypeOpaque, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mutated-secret-2", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mutated-secret-3", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter3"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + + expectedSyncedRelatedObjects: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-1", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter1"), + }, + Type: corev1.SecretTypeOpaque, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-2", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-3", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter3"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + }, + + { + name: "empty items on either side should be silently skipped", + workspace: "empty-items-multi-references", + remoteMainResource: crds.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "default", + }, + Spec: crds.BackupSpec{ + Items: []crds.BackupItem{ + {Name: "secret-1"}, + {Name: ""}, + {Name: "secret-3"}, + }, + }, + }, + localMainResource: crds.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "synced-default", + }, + Spec: crds.BackupSpec{ + Items: []crds.BackupItem{ + {Name: "mutated-secret-1"}, + {Name: "mutated-secret-2"}, + {Name: ""}, + }, + }, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "credentials", + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "Secret", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.items.#.name", + }, + }, + }, + }, + sourceRelatedObjects: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mutated-secret-1", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter1"), + }, + Type: corev1.SecretTypeOpaque, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mutated-secret-2", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + + expectedSyncedRelatedObjects: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-1", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter1"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + ctx := t.Context() + + // setup a test environment in kcp + orgKubconfig := utils.CreateOrganization(t, ctx, testcase.workspace, apiExportName) + + // start a service cluster + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ + "test/crds/backup.yaml", + }) + + // publish Backups + t.Logf("Publishing CRDs…") + prBackups := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-backups", + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "eksempel.no", + Version: "v1", + Kind: "Backup", + }, + // These rules make finding the local object easier, but should not be used in production. + Naming: &syncagentv1alpha1.ResourceNaming{ + Name: "{{ .Object.metadata.name }}", + Namespace: "synced-{{ .Object.metadata.namespace }}", + }, + Projection: &syncagentv1alpha1.ResourceProjection{ + Group: "kcp.example.com", + }, + Related: []syncagentv1alpha1.RelatedResourceSpec{testcase.relatedConfig}, + }, + } + + if err := envtestClient.Create(ctx, prBackups); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // pre-create the synced Backup because it's easier to let the agent deal with updating, + // rather than us here having to implement to update/patch logic. + t.Log("Creating synced Backup copy locally…") + + ensureNamespace(t, ctx, envtestClient, testcase.localMainResource.Namespace) + + localBackup := utils.ToUnstructured(t, &testcase.localMainResource) + localBackup.SetAPIVersion("eksempel.no/v1") + localBackup.SetKind("Backup") + + if err := envtestClient.Create(ctx, localBackup); err != nil { + t.Fatalf("Failed to create local Backup: %v", err) + } + + // fake operator: create credential Secrets + teamCtx := kontext.WithCluster(ctx, logicalcluster.Name(fmt.Sprintf("root:%s:team-1", testcase.workspace))) + kcpClient := utils.GetKcpAdminClusterClient(t) + + originClient := envtestClient + originContext := ctx + destClient := kcpClient + destContext := teamCtx + + if testcase.relatedConfig.Origin == syncagentv1alpha1.RelatedResourceOriginKcp { + originClient, destClient = destClient, originClient + originContext, destContext = destContext, originContext + } + + for _, relatedObject := range testcase.sourceRelatedObjects { + t.Logf("Creating credential Secret on the %s side…", testcase.relatedConfig.Origin) + + ensureNamespace(t, originContext, originClient, relatedObject.Namespace) + + if err := originClient.Create(originContext, &relatedObject); err != nil { + t.Fatalf("Failed to create Secret %s: %v", relatedObject.Name, err) + } + } + + // start the agent in the background to update the APIExport with the Backups API + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + + // wait until the API is available + utils.WaitForBoundAPI(t, teamCtx, kcpClient, schema.GroupVersionResource{ + Group: apiExportName, + Version: "v1", + Resource: "backups", + }) + + // create a Backup object in a team workspace + t.Log("Creating Backup in kcp…") + + remoteBackup := utils.ToUnstructured(t, &testcase.remoteMainResource) + remoteBackup.SetAPIVersion("kcp.example.com/v1") + remoteBackup.SetKind("Backup") + + if err := kcpClient.Create(teamCtx, remoteBackup); err != nil { + t.Fatalf("Failed to create Backup in kcp: %v", err) + } + + // wait for the agent to do its magic + t.Log("Wait for Secrets to be synced…") + checkSecret := func(ctx context.Context, expected corev1.Secret) error { + copySecret := &corev1.Secret{} + if err := destClient.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(&expected), copySecret); err != nil { + return fmt.Errorf("failed to get copy of Secret %v: %w", ctrlruntimeclient.ObjectKeyFromObject(&expected), err) + } + + if err := compareSecrets(*copySecret, expected); err != nil { + return fmt.Errorf("synced secret does not match expected Secret:\n%w", err) + } + + return nil + } + + err := wait.PollUntilContextTimeout(destContext, 2*time.Second, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { + var errs []string + + for _, expectedObj := range testcase.expectedSyncedRelatedObjects { + if err := checkSecret(ctx, expectedObj); err != nil { + errs = append(errs, fmt.Sprintf("invalid Secret %s: %v", expectedObj.Name, err)) + } + } + + if len(errs) > 0 { + t.Logf("Sync has not completed yet:\n%s", strings.Join(errs, "\n")) + return false, nil + } + + return true, nil + }) + if err != nil { + t.Fatalf("Failed to wait for Secrets to be synced: %v", err) + } + }) + } +} + +func compareSecrets(actual, expected corev1.Secret) error { + // ensure the secret in kcp does not have any sync-related metadata + maps.DeleteFunc(actual.Labels, func(k, v string) bool { + return strings.HasPrefix(k, "claimed.internal.apis.kcp.io/") + }) + + delete(actual.Annotations, "kcp.io/cluster") + if len(actual.Annotations) == 0 { + actual.Annotations = nil + } + + actual.CreationTimestamp = expected.CreationTimestamp + actual.Generation = expected.Generation + actual.ResourceVersion = expected.ResourceVersion + actual.ManagedFields = expected.ManagedFields + actual.UID = expected.UID + + if changes := diff.ObjectDiff(expected, actual); changes != "" { + return errors.New(changes) + } + + return nil +} From d660def8011b1ba608f722d8dea40f8e345f544a Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Fri, 27 Jun 2025 10:38:18 +0200 Subject: [PATCH 4/5] update docs On-behalf-of: @SAP christoph.mewes@sap.com --- docs/content/publish-resources/index.md | 31 ++++++++++++++----------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/content/publish-resources/index.md b/docs/content/publish-resources/index.md index cc610d1..f36f67f 100644 --- a/docs/content/publish-resources/index.md +++ b/docs/content/publish-resources/index.md @@ -303,20 +303,23 @@ existing" and not create an error. #### References -A reference is a JSONPath-like expression that are evaluated on both sides of the synchronization. -You configure a single path expression (like `spec.secretName`) and the sync agent will evaluate it -in the original primary object (in kcp) and again in the copied primary object (on the service -cluster). Since the primary object has already been mutated, the `spec.secretName` is already -rewritten/adjusted to work on the service cluster (for example it was changed from `my-secret` to -`jk23h4wz47329rz2r72r92-secret` on the service cluster side). By doing it this way, admins only have -to think about mutations and rewrites once (when configuring the primary object in the -PublishedResource) and the path will yield 2 ready to use values (`my-secret` and the computed value). - -The value selected by the path expression must be a string (or number, but it will be coalesced into -a string) and can then be further adjusted by applying a regular expression to it. - -References can only ever select one related object. Their upside is that they are simple to understand -and easy to use, but require a "link" in the primary object that would point to the related object. +A reference is a JSONPath-like expression (more precisely, it follows the [gjson syntax](https://github.com/tidwall/gjson?tab=readme-ov-file#path-syntax)) +that are evaluated on both sides of the synchronization. You configure a single path expression +(like `spec.secretName`) and the sync agent will evaluate it in the original primary object (in kcp) +and again in the copied primary object (on the service cluster). Since the primary object has already +been mutated, the `spec.secretName` is already rewritten/adjusted to work on the service cluster +(for example it was changed from `my-secret` to `jk23h4wz47329rz2r72r92-secret` on the service +cluster side). By doing it this way, admins only have to think about mutations and rewrites once +(when configuring the primary object in the PublishedResource) and the path will yield 2 ready to +use values (`my-secret` and the computed value). + +References can either return a single scalar (strings or integers that will be auto-converted to a +string) (like in `spec.secretName`) or a list of strings/numbers (like `spec.users.#.name`). A +reference must return the same number of items on both the local and remote object, otherwise the +agent will not be able to map local related names to remote related names correctly. + +A regular expression can be configured to be applied to each found value (i.e. if the reference returns +a list of values, the regular expression is applied to each individual value). Here's an example on how to use references to locate the related object. From 4e8761daf1b051ae15fdbd53fee6069d3c39d560 Mon Sep 17 00:00:00 2001 From: Christoph Mewes Date: Fri, 27 Jun 2025 14:58:48 +0200 Subject: [PATCH 5/5] fix docs On-behalf-of: @SAP christoph.mewes@sap.com --- docs/generators/crd-ref/crd.template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/generators/crd-ref/crd.template.md b/docs/generators/crd-ref/crd.template.md index 7ac1aa5..aed0344 100644 --- a/docs/generators/crd-ref/crd.template.md +++ b/docs/generators/crd-ref/crd.template.md @@ -4,8 +4,8 @@ description: | {{- if .Description }} {{ .Description | indent 2 }} {{- else }} - Custom resource definition (CRD) schema reference page for the {{ .Title }} - resource ({{ .NamePlural }}.{{ .Group }}), as part of the Giant Swarm + Custom resource definition (CRD) schema reference page for the {{ .Title }} + resource ({{ .NamePlural }}.{{ .Group }}), as part of the Giant Swarm Management API documentation. {{- end }} weight: {{ .Weight }} @@ -78,7 +78,7 @@ This CRD is being replaced by {{ .ShortName }} {{with .Description}}
-{{.|markdown}} +{% raw %}{{.|markdown}}{% endraw %}
{{end}}