diff --git a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml index fec0d4f..6db24cf 100644 --- a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml +++ b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml @@ -47,6 +47,14 @@ spec: PublishedResourceSpec describes the desired resource publication from a service cluster to kcp. properties: + enableWorkspacePaths: + description: |- + EnableWorkspacePaths toggles whether the Sync Agent will not just store the kcp + cluster name as a label on each locally synced object, but also the full workspace + path. This is optional because it requires additional requests to kcp and + should only be used if the workspace path is of interest on the + service cluster side. + type: boolean filter: description: |- If specified, the filter will be applied to the resources in a workspace diff --git a/go.mod b/go.mod index 3d7ab7b..2d5a191 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.3.3 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 51c2f8b..deb7aaf 100644 --- a/go.sum +++ b/go.sum @@ -229,8 +229,8 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= -github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/controller/sync/controller.go b/internal/controller/sync/controller.go index e788ea5..c00d3e9 100644 --- a/internal/controller/sync/controller.go +++ b/internal/controller/sync/controller.go @@ -30,7 +30,11 @@ import ( "github.com/kcp-dev/api-syncagent/internal/sync" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + kcpcore "github.com/kcp-dev/kcp/sdk/apis/core" + kcpdevcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" @@ -52,6 +56,7 @@ type Reconciler struct { log *zap.SugaredLogger syncer *sync.ResourceSyncer remoteDummy *unstructured.Unstructured + pubRes *syncagentv1alpha1.PublishedResource } // Create creates a new controller and importantly does *not* add it to the manager, @@ -99,6 +104,7 @@ func Create( log: log, remoteDummy: remoteDummy, syncer: syncer, + pubRes: pubRes, } ctrlOptions := controller.Options{ @@ -152,8 +158,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( return reconcile.Result{}, nil } + syncContext := sync.NewContext(ctx, wsCtx) + + // if desired, fetch the cluster path as well (some downstream service providers might make use of it, + // but since it requires an additional permission claim, it's optional) + if r.pubRes.Spec.EnableWorkspacePaths { + lc := &kcpdevcorev1alpha1.LogicalCluster{} + if err := r.vwClient.Get(wsCtx, types.NamespacedName{Name: kcpdevcorev1alpha1.LogicalClusterName}, lc); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to retrieve remote logicalcluster: %w", err) + } + + path := lc.Annotations[kcpcore.LogicalClusterPathAnnotationKey] + syncContext = syncContext.WithWorkspacePath(logicalcluster.NewPath(path)) + } + // sync main object - requeue, err := r.syncer.Process(sync.NewContext(ctx, wsCtx), remoteObj) + requeue, err := r.syncer.Process(syncContext, remoteObj) if err != nil { return reconcile.Result{}, err } diff --git a/internal/controller/syncmanager/lifecycle/cluster.go b/internal/controller/syncmanager/lifecycle/cluster.go index 0899d7f..c0ee62b 100644 --- a/internal/controller/syncmanager/lifecycle/cluster.go +++ b/internal/controller/syncmanager/lifecycle/cluster.go @@ -27,7 +27,10 @@ import ( "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" + kcpdevcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/cache" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -100,18 +103,18 @@ func (c clusterRoundTripper) RoundTrip(req *http.Request) (*http.Response, error var apiRegex = regexp.MustCompile(`(/api/|/apis/)`) // generatePath formats the request path to target the specified cluster. -func generatePath(originalPath string, clusterPath logicalcluster.Path) string { +func generatePath(originalPath string, workspacePath logicalcluster.Path) string { // If the originalPath already has cluster.Path() then the path was already modifed and no change needed - if strings.Contains(originalPath, clusterPath.RequestPath()) { + if strings.Contains(originalPath, workspacePath.RequestPath()) { return originalPath } // If the originalPath has /api/ or /apis/ in it, it might be anywhere in the path, so we use a regex to find and // replaces /api/ or /apis/ with $cluster/api/ or $cluster/apis/ if apiRegex.MatchString(originalPath) { - return apiRegex.ReplaceAllString(originalPath, fmt.Sprintf("%s$1", clusterPath.RequestPath())) + return apiRegex.ReplaceAllString(originalPath, fmt.Sprintf("%s$1", workspacePath.RequestPath())) } // Otherwise, we're just prepending /clusters/$name - path := clusterPath.RequestPath() + path := workspacePath.RequestPath() // if the original path is relative, add a / separator if len(originalPath) > 0 && originalPath[0] != '/' { path += "/" @@ -130,7 +133,14 @@ func NewCluster(address string, baseRestConfig *rest.Config) (*Cluster, error) { return newClusterAwareRoundTripper(rt) }) + scheme := runtime.NewScheme() + + if err := kcpdevcorev1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to register scheme %s: %w", kcpdevcorev1alpha1.SchemeGroupVersion, err) + } + clusterObj, err := cluster.New(config, func(o *cluster.Options) { + o.Scheme = scheme o.NewCache = kcp.NewClusterAwareCache o.NewAPIReader = kcp.NewClusterAwareAPIReader o.NewClient = kcp.NewClusterAwareClient diff --git a/internal/sync/context.go b/internal/sync/context.go index 5042d08..92a0447 100644 --- a/internal/sync/context.go +++ b/internal/sync/context.go @@ -19,13 +19,16 @@ package sync import ( "context" + "github.com/kcp-dev/logicalcluster/v3" + "sigs.k8s.io/controller-runtime/pkg/kontext" ) type Context struct { - clusterName string - local context.Context - remote context.Context + clusterName logicalcluster.Name + workspacePath logicalcluster.Path + local context.Context + remote context.Context } func NewContext(local, remote context.Context) Context { @@ -35,8 +38,17 @@ func NewContext(local, remote context.Context) Context { } return Context{ - clusterName: string(clusterName), + clusterName: clusterName, local: local, remote: remote, } } + +func (c *Context) WithWorkspacePath(path logicalcluster.Path) Context { + return Context{ + clusterName: c.clusterName, + workspacePath: path, + local: c.local, + remote: c.remote, + } +} diff --git a/internal/sync/context_test.go b/internal/sync/context_test.go index 20a928c..51199c1 100644 --- a/internal/sync/context_test.go +++ b/internal/sync/context_test.go @@ -31,7 +31,7 @@ func TestNewContext(t *testing.T) { combinedCtx := NewContext(context.Background(), ctx) - if combinedCtx.clusterName != clusterName.String() { + if combinedCtx.clusterName != clusterName { t.Fatalf("Expected function to recognize the cluster name in the context, but got %q", combinedCtx.clusterName) } } diff --git a/internal/sync/meta.go b/internal/sync/meta.go index 1cc9e24..381248a 100644 --- a/internal/sync/meta.go +++ b/internal/sync/meta.go @@ -19,6 +19,7 @@ package sync import ( "context" + "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -65,7 +66,7 @@ func ensureFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlrun finalizers.Insert(deletionFinalizer) obj.SetFinalizers(sets.List(finalizers)) - log.Debugw("Adding finalizer…", "on", newObjectKey(obj, ""), "finalizer", finalizer) + log.Debugw("Adding finalizer…", "on", newObjectKey(obj, "", logicalcluster.None), "finalizer", finalizer) if err := client.Patch(ctx, obj, ctrlruntimeclient.MergeFrom(original)); err != nil { return false, err } @@ -84,7 +85,7 @@ func removeFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlrun finalizers.Delete(deletionFinalizer) obj.SetFinalizers(sets.List(finalizers)) - log.Debugw("Removing finalizer…", "on", newObjectKey(obj, ""), "finalizer", finalizer) + log.Debugw("Removing finalizer…", "on", newObjectKey(obj, "", logicalcluster.None), "finalizer", finalizer) if err := client.Patch(ctx, obj, ctrlruntimeclient.MergeFrom(original)); err != nil { return false, err } @@ -93,16 +94,18 @@ func removeFinalizer(ctx context.Context, log *zap.SugaredLogger, client ctrlrun } type objectKey struct { - Cluster string - Namespace string - Name string + ClusterName logicalcluster.Name + WorkspacePath logicalcluster.Path + Namespace string + Name string } -func newObjectKey(obj metav1.Object, clusterName string) objectKey { +func newObjectKey(obj metav1.Object, clusterName logicalcluster.Name, workspacePath logicalcluster.Path) objectKey { return objectKey{ - Cluster: clusterName, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), + ClusterName: clusterName, + WorkspacePath: workspacePath, + Namespace: obj.GetNamespace(), + Name: obj.GetName(), } } @@ -111,8 +114,8 @@ func (k objectKey) String() string { if k.Namespace != "" { result = k.Namespace + "/" + result } - if k.Cluster != "" { - result = k.Cluster + "|" + result + if k.ClusterName != "" { + result = string(k.ClusterName) + "|" + result } return result @@ -123,8 +126,8 @@ func (k objectKey) Key() string { if k.Namespace != "" { result = k.Namespace + "_" + result } - if k.Cluster != "" { - result = k.Cluster + "_" + result + if k.ClusterName != "" { + result = string(k.ClusterName) + "_" + result } return result @@ -132,8 +135,18 @@ func (k objectKey) Key() string { func (k objectKey) Labels() labels.Set { return labels.Set{ - remoteObjectClusterLabel: k.Cluster, + remoteObjectClusterLabel: string(k.ClusterName), remoteObjectNamespaceLabel: k.Namespace, remoteObjectNameLabel: k.Name, } } + +func (k objectKey) Annotations() labels.Set { + s := labels.Set{} + + if !k.WorkspacePath.Empty() { + s[remoteObjectWorkspacePathAnnotation] = k.WorkspacePath.String() + } + + return s +} diff --git a/internal/sync/meta_test.go b/internal/sync/meta_test.go index ec1f1bd..5b3e986 100644 --- a/internal/sync/meta_test.go +++ b/internal/sync/meta_test.go @@ -19,6 +19,8 @@ package sync import ( "testing" + "github.com/kcp-dev/logicalcluster/v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -33,9 +35,10 @@ func createNewObject(name, namespace string) metav1.Object { func TestObjectKey(t *testing.T) { testcases := []struct { - object metav1.Object - clusterName string - expected string + object metav1.Object + clusterName logicalcluster.Name + workspacePath logicalcluster.Path + expected string }{ { object: createNewObject("test", ""), @@ -57,11 +60,17 @@ func TestObjectKey(t *testing.T) { clusterName: "abc123", expected: "abc123|namespace/test", }, + { + object: createNewObject("test", "namespace"), + clusterName: "abc123", + workspacePath: logicalcluster.NewPath("this:should:not:appear:in:the:key"), + expected: "abc123|namespace/test", + }, } for _, testcase := range testcases { t.Run("", func(t *testing.T) { - key := newObjectKey(testcase.object, testcase.clusterName) + key := newObjectKey(testcase.object, testcase.clusterName, testcase.workspacePath) if stringified := key.String(); stringified != testcase.expected { t.Fatalf("Expected %q but got %q.", testcase.expected, stringified) diff --git a/internal/sync/object_syncer.go b/internal/sync/object_syncer.go index 7efc5a9..34630c9 100644 --- a/internal/sync/object_syncer.go +++ b/internal/sync/object_syncer.go @@ -22,6 +22,7 @@ import ( "slices" jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" "k8c.io/reconciler/pkg/equality" @@ -53,10 +54,11 @@ type objectSyncer struct { } type syncSide struct { - ctx context.Context - clusterName string - client ctrlruntimeclient.Client - object *unstructured.Unstructured + ctx context.Context + clusterName logicalcluster.Name + workspacePath logicalcluster.Path + client ctrlruntimeclient.Client + object *unstructured.Unstructured } func (s *objectSyncer) Sync(log *zap.SugaredLogger, source, dest syncSide) (requeue bool, err error) { @@ -104,7 +106,7 @@ func (s *objectSyncer) Sync(log *zap.SugaredLogger, source, dest syncSide) (requ // do not try to update a destination object that is in deletion // (this should only happen if a service admin manually deletes something on the service cluster) if dest.object.GetDeletionTimestamp() != nil { - log.Debugw("Destination object is in deletion, skipping any further synchronization", "dest-object", newObjectKey(dest.object, dest.clusterName)) + log.Debugw("Destination object is in deletion, skipping any further synchronization", "dest-object", newObjectKey(dest.object, dest.clusterName, logicalcluster.None)) return false, nil } @@ -173,7 +175,7 @@ func (s *objectSyncer) syncObjectSpec(log *zap.SugaredLogger, source, dest syncS sourceObjCopy := source.object.DeepCopy() stripMetadata(sourceObjCopy) - log = log.With("dest-object", newObjectKey(dest.object, dest.clusterName)) + log = log.With("dest-object", newObjectKey(dest.object, dest.clusterName, logicalcluster.None)) // calculate the patch to go from the last known state to the current source object's state if lastKnownSourceState != nil { @@ -181,6 +183,11 @@ 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()) + // now we can diff the two versions and create a patch rawPatch, err := s.createMergePatch(lastKnownSourceState, sourceObjCopy) if err != nil { @@ -271,11 +278,14 @@ func (s *objectSyncer) ensureDestinationObject(log *zap.SugaredLogger, source, d stripMetadata(destObj) // remember the connection between the source and destination object - sourceObjKey := newObjectKey(source.object, source.clusterName) + sourceObjKey := newObjectKey(source.object, source.clusterName, source.workspacePath) ensureLabels(destObj, sourceObjKey.Labels()) + // 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)) + objectLog := log.With("dest-object", newObjectKey(destObj, dest.clusterName, logicalcluster.None)) objectLog.Debugw("Creating destination object…") if err := dest.client.Create(dest.ctx, destObj); err != nil { @@ -316,6 +326,7 @@ 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()) + ensureAnnotations(existingDestObj, sourceKey.Annotations()) if err := dest.client.Update(dest.ctx, existingDestObj); err != nil { return fmt.Errorf("failed to upsert current destination object labels: %w", err) @@ -356,7 +367,7 @@ func (s *objectSyncer) handleDeletion(log *zap.SugaredLogger, source, dest syncS // if the destination object still exists, delete it and wait for it to be cleaned up if dest.object != nil { if dest.object.GetDeletionTimestamp() == nil { - log.Debugw("Deleting destination object…", "dest-object", newObjectKey(dest.object, dest.clusterName)) + log.Debugw("Deleting destination object…", "dest-object", newObjectKey(dest.object, dest.clusterName, logicalcluster.None)) if err := dest.client.Delete(dest.ctx, dest.object); err != nil { return false, fmt.Errorf("failed to delete destination object: %w", err) } diff --git a/internal/sync/state_store.go b/internal/sync/state_store.go index 32a5c6e..bf9e048 100644 --- a/internal/sync/state_store.go +++ b/internal/sync/state_store.go @@ -23,6 +23,8 @@ import ( "fmt" "strings" + "github.com/kcp-dev/logicalcluster/v3" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -32,7 +34,7 @@ import ( type ObjectStateStore interface { Get(source syncSide) (*unstructured.Unstructured, error) - Put(obj *unstructured.Unstructured, clusterName string, subresources []string) error + Put(obj *unstructured.Unstructured, clusterName logicalcluster.Name, subresources []string) error } // objectStateStore is capable of creating/updating a target Kubernetes object @@ -71,7 +73,7 @@ func (op *objectStateStore) Get(source syncSide) (*unstructured.Unstructured, er return lastKnown, nil } -func (op *objectStateStore) Put(obj *unstructured.Unstructured, clusterName string, subresources []string) error { +func (op *objectStateStore) Put(obj *unstructured.Unstructured, clusterName logicalcluster.Name, subresources []string) error { encoded, err := op.snapshotObject(obj, subresources) if err != nil { return err @@ -99,8 +101,8 @@ func (op *objectStateStore) snapshotObject(obj *unstructured.Unstructured, subre } type backend interface { - Get(obj *unstructured.Unstructured, clusterName string) ([]byte, error) - Put(obj *unstructured.Unstructured, clusterName string, data []byte) error + Get(obj *unstructured.Unstructured, clusterName logicalcluster.Name) ([]byte, error) + Put(obj *unstructured.Unstructured, clusterName logicalcluster.Name, data []byte) error } type kubernetesBackend struct { @@ -131,7 +133,7 @@ func hashObject(obj *unstructured.Unstructured) string { func newKubernetesBackend(namespace string, primaryObject, stateCluster syncSide) *kubernetesBackend { keyHash := hashObject(primaryObject.object) - secretLabels := newObjectKey(primaryObject.object, primaryObject.clusterName).Labels() + secretLabels := newObjectKey(primaryObject.object, primaryObject.clusterName, primaryObject.workspacePath).Labels() secretLabels[objectStateLabelName] = objectStateLabelValue return &kubernetesBackend{ @@ -145,13 +147,13 @@ func newKubernetesBackend(namespace string, primaryObject, stateCluster syncSide } } -func (b *kubernetesBackend) Get(obj *unstructured.Unstructured, clusterName string) ([]byte, error) { +func (b *kubernetesBackend) Get(obj *unstructured.Unstructured, clusterName logicalcluster.Name) ([]byte, error) { secret := corev1.Secret{} if err := b.stateCluster.client.Get(b.stateCluster.ctx, b.secretName, &secret); ctrlruntimeclient.IgnoreNotFound(err) != nil { return nil, err } - sourceKey := newObjectKey(obj, clusterName).Key() + sourceKey := newObjectKey(obj, clusterName, logicalcluster.None).Key() data, ok := secret.Data[sourceKey] if !ok { return nil, nil @@ -160,7 +162,7 @@ func (b *kubernetesBackend) Get(obj *unstructured.Unstructured, clusterName stri return data, nil } -func (b *kubernetesBackend) Put(obj *unstructured.Unstructured, clusterName string, data []byte) error { +func (b *kubernetesBackend) Put(obj *unstructured.Unstructured, clusterName logicalcluster.Name, data []byte) error { secret := corev1.Secret{} if err := b.stateCluster.client.Get(b.stateCluster.ctx, b.secretName, &secret); ctrlruntimeclient.IgnoreNotFound(err) != nil { return err @@ -170,7 +172,7 @@ func (b *kubernetesBackend) Put(obj *unstructured.Unstructured, clusterName stri secret.Data = map[string][]byte{} } - sourceKey := newObjectKey(obj, clusterName).Key() + sourceKey := newObjectKey(obj, clusterName, logicalcluster.None).Key() secret.Data[sourceKey] = data secret.Labels = b.labels diff --git a/internal/sync/syncer.go b/internal/sync/syncer.go index 361c224..3287fb6 100644 --- a/internal/sync/syncer.go +++ b/internal/sync/syncer.go @@ -19,7 +19,6 @@ package sync import ( "fmt" - "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" "github.com/kcp-dev/api-syncagent/internal/mutation" @@ -113,7 +112,7 @@ func NewResourceSyncer( // case, the caller should re-fetch the remote object and call Process() again (most likely in the // next reconciliation). Only when (false, nil) is returned is the entire process finished. func (s *ResourceSyncer) Process(ctx Context, remoteObj *unstructured.Unstructured) (requeue bool, err error) { - log := s.log.With("source-object", newObjectKey(remoteObj, ctx.clusterName)) + log := s.log.With("source-object", newObjectKey(remoteObj, ctx.clusterName, ctx.workspacePath)) // find the local equivalent object in the local service cluster localObj, err := s.findLocalObject(ctx, remoteObj) @@ -127,10 +126,11 @@ func (s *ResourceSyncer) Process(ctx Context, remoteObj *unstructured.Unstructur // Prepare object sync sides. sourceSide := syncSide{ - ctx: ctx.remote, - clusterName: ctx.clusterName, - client: s.remoteClient, - object: remoteObj, + ctx: ctx.remote, + clusterName: ctx.clusterName, + workspacePath: ctx.workspacePath, + client: s.remoteClient, + object: remoteObj, } destSide := syncSide{ @@ -182,7 +182,7 @@ func (s *ResourceSyncer) Process(ctx Context, remoteObj *unstructured.Unstructur } func (s *ResourceSyncer) findLocalObject(ctx Context, remoteObj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - localSelector := labels.SelectorFromSet(newObjectKey(remoteObj, ctx.clusterName).Labels()) + localSelector := labels.SelectorFromSet(newObjectKey(remoteObj, ctx.clusterName, ctx.workspacePath).Labels()) localObjects := &unstructured.UnstructuredList{} localObjects.SetAPIVersion(s.destDummy.GetAPIVersion()) @@ -215,7 +215,7 @@ func (s *ResourceSyncer) createLocalObjectCreator(ctx Context) objectCreatorFunc destScope := syncagentv1alpha1.ResourceScope(s.localCRD.Spec.Scope) // map namespace/name - mappedName := projection.GenerateLocalObjectName(s.pubRes, remoteObj, logicalcluster.Name(ctx.clusterName)) + mappedName := projection.GenerateLocalObjectName(s.pubRes, remoteObj, ctx.clusterName) switch destScope { case syncagentv1alpha1.ClusterScoped: diff --git a/internal/sync/syncer_test.go b/internal/sync/syncer_test.go index 375b5a3..79e3e7a 100644 --- a/internal/sync/syncer_test.go +++ b/internal/sync/syncer_test.go @@ -191,7 +191,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -217,7 +217,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -264,7 +264,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -321,7 +321,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -357,7 +357,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -383,7 +383,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Miss Scarlet", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -445,7 +445,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Username: "Colonel Mustard", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -574,7 +574,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":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + existingState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, expectedRemoteObject: newUnstructured(&dummyv1alpha1.Thing{ ObjectMeta: metav1.ObjectMeta{ @@ -602,7 +602,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { Address: "Hotdogstr. 13", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Miss Scarlet"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -825,7 +825,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { if backend == nil { backend = newKubernetesBackend(stateNamespace, primaryObject, stateCluster) if testcase.existingState != "" { - if err := backend.Put(testcase.remoteObject, clusterName.String(), []byte(testcase.existingState)); err != nil { + if err := backend.Put(testcase.remoteObject, clusterName, []byte(testcase.existingState)); err != nil { t.Fatalf("Failed to prime state store: %v", err) } } @@ -894,7 +894,7 @@ func TestSyncerProcessingSingleResourceWithoutStatus(t *testing.T) { t.Fatal("Cannot check object state, state store was never instantiated.") } - finalState, err := backend.Get(testcase.expectedRemoteObject, clusterName.String()) + finalState, err := backend.Get(testcase.expectedRemoteObject, clusterName) if err != nil { t.Fatalf("Failed to get final state: %v", err) } else if !bytes.Equal(finalState, []byte(testcase.expectedState)) { @@ -1014,7 +1014,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { CurrentVersion: "v1", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, ///////////////////////////////////////////////////////////////////////////////// @@ -1085,7 +1085,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { CurrentVersion: "v1", }, }), - expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, + expectedState: `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"annotations":{},"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}`, }, } @@ -1122,7 +1122,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { if backend == nil { backend = newKubernetesBackend(stateNamespace, primaryObject, stateCluster) if testcase.existingState != "" { - if err := backend.Put(testcase.remoteObject, clusterName.String(), []byte(testcase.existingState)); err != nil { + if err := backend.Put(testcase.remoteObject, clusterName, []byte(testcase.existingState)); err != nil { t.Fatalf("Failed to prime state store: %v", err) } } @@ -1191,7 +1191,7 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) { t.Fatal("Cannot check object state, state store was never instantiated.") } - finalState, err := backend.Get(testcase.expectedRemoteObject, clusterName.String()) + finalState, err := backend.Get(testcase.expectedRemoteObject, clusterName) if err != nil { t.Fatalf("Failed to get final state: %v", err) } else if !bytes.Equal(finalState, []byte(testcase.expectedState)) { diff --git a/internal/sync/types.go b/internal/sync/types.go index 7f0ee0c..cd08049 100644 --- a/internal/sync/types.go +++ b/internal/sync/types.go @@ -21,13 +21,16 @@ const ( // them from being deleted before the local objects can be cleaned up. deletionFinalizer = "syncagent.kcp.io/cleanup" - // The following 3 labels are put on local objects to link them to their - // origin remote objects. + // The following 4 labels/annotations are put on local objects to link them to their + // 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" + remoteObjectWorkspacePathAnnotation = "syncagent.kcp.io/remote-object-workspace-path" + // objectStateLabelName is put on object state Secrets to allow for easier mass deletions // if ever necessary. objectStateLabelName = "syncagent.kcp.io/object-state" diff --git a/sdk/apis/syncagent/v1alpha1/published_resource.go b/sdk/apis/syncagent/v1alpha1/published_resource.go index 6906ec5..8cd6e03 100644 --- a/sdk/apis/syncagent/v1alpha1/published_resource.go +++ b/sdk/apis/syncagent/v1alpha1/published_resource.go @@ -68,6 +68,13 @@ type PublishedResourceSpec struct { // many different kcp workspaces. Naming *ResourceNaming `json:"naming,omitempty"` + // EnableWorkspacePaths toggles whether the Sync Agent will not just store the kcp + // cluster name as a label on each locally synced object, but also the full workspace + // path. This is optional because it requires additional requests to kcp and + // should only be used if the workspace path is of interest on the + // service cluster side. + EnableWorkspacePaths bool `json:"enableWorkspacePaths,omitempty"` + // Projection is used to change the GVK of a published resource within kcp. // This can be used to hide implementation details and provide a customized API // experience to the user. diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go b/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go index 725ae62..e242b4a 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go @@ -21,11 +21,12 @@ package v1alpha1 // PublishedResourceSpecApplyConfiguration represents a declarative configuration of the PublishedResourceSpec type for use // with apply. type PublishedResourceSpecApplyConfiguration struct { - Resource *SourceResourceDescriptorApplyConfiguration `json:"resource,omitempty"` - Filter *ResourceFilterApplyConfiguration `json:"filter,omitempty"` - Naming *ResourceNamingApplyConfiguration `json:"naming,omitempty"` - Projection *ResourceProjectionApplyConfiguration `json:"projection,omitempty"` - Related []RelatedResourceSpecApplyConfiguration `json:"related,omitempty"` + Resource *SourceResourceDescriptorApplyConfiguration `json:"resource,omitempty"` + Filter *ResourceFilterApplyConfiguration `json:"filter,omitempty"` + Naming *ResourceNamingApplyConfiguration `json:"naming,omitempty"` + EnableWorkspacePaths *bool `json:"enableWorkspacePaths,omitempty"` + Projection *ResourceProjectionApplyConfiguration `json:"projection,omitempty"` + Related []RelatedResourceSpecApplyConfiguration `json:"related,omitempty"` } // PublishedResourceSpecApplyConfiguration constructs a declarative configuration of the PublishedResourceSpec type for use with @@ -58,6 +59,14 @@ func (b *PublishedResourceSpecApplyConfiguration) WithNaming(value *ResourceNami return b } +// WithEnableWorkspacePaths sets the EnableWorkspacePaths field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the EnableWorkspacePaths field is set to the value of the last call. +func (b *PublishedResourceSpecApplyConfiguration) WithEnableWorkspacePaths(value bool) *PublishedResourceSpecApplyConfiguration { + b.EnableWorkspacePaths = &value + return b +} + // WithProjection sets the Projection field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Projection field is set to the value of the last call.