diff --git a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml index 9fd7551..c13ccd6 100644 --- a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml +++ b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml @@ -407,33 +407,148 @@ spec: type: object type: array type: object - origin: - description: '"service" or "kcp"' - type: string - reference: + object: + description: |- + Object describes how the related resource can be found on the origin side + and where it is to supposed to be created on the destination side. properties: - name: + namespace: + description: |- + Namespace configures in what namespace the related object resides in. If + not specified, the same namespace as the main object is assumed. If the + main object is cluster-scoped, this field is required and an error will be + raised during syncing if the field is not specified. properties: - path: - type: string - regex: + reference: + description: |- + Reference points to a field inside the main object. This reference is + evaluated on both source and destination sides to find the related object. properties: - pattern: + path: description: |- - Pattern can be left empty to simply replace the entire value with the - replacement. + Path is a simplified JSONPath expression like "metadata.name". A reference + must always select at least _something_ in the object, even if the value + is discarded by the regular expression. type: string - replacement: + regex: + description: |- + Regex is a Go regular expression that is optionally applied to the selected + value from the path. + properties: + pattern: + description: |- + Pattern can be left empty to simply replace the entire value with the + replacement. + type: string + replacement: + description: |- + Replacement is the string that the matched pattern is replaced with. It + can contain references to groups in the pattern by using \N. + type: string + type: object + required: + - path + type: object + selector: + description: |- + Selector is a label selector that is useful if no reference is in the + main resource (i.e. if the related object links back to its parent, instead + of the parent pointing to the related object). + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + rewrite: + properties: + regex: + description: |- + Regex is a Go regular expression that is optionally applied to the selected + value from the path. + properties: + pattern: + description: |- + Pattern can be left empty to simply replace the entire value with the + replacement. + type: string + replacement: + description: |- + Replacement is the string that the matched pattern is replaced with. It + can contain references to groups in the pattern by using \N. + type: string + type: object + template: + description: |- + TemplateExpression is a Go templated string that can make use of variables to + construct the resulting string. + properties: + template: + type: string + type: object + type: object + required: + - rewrite + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template is a Go templated string that can make use of variables to + construct the resulting string. + properties: + template: type: string type: object - required: - - path type: object - namespace: + reference: + description: |- + Reference points to a field inside the main object. This reference is + evaluated on both source and destination sides to find the related object. properties: path: + description: |- + Path is a simplified JSONPath expression like "metadata.name". A reference + must always select at least _something_ in the object, even if the value + is discarded by the regular expression. type: string regex: + description: |- + Regex is a Go regular expression that is optionally applied to the selected + value from the path. properties: pattern: description: |- @@ -441,19 +556,107 @@ spec: replacement. type: string replacement: + description: |- + Replacement is the string that the matched pattern is replaced with. It + can contain references to groups in the pattern by using \N. type: string type: object required: - path type: object - required: - - name + selector: + description: |- + Selector is a label selector that is useful if no reference is in the + main resource (i.e. if the related object links back to its parent, instead + of the parent pointing to the related object). + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + rewrite: + properties: + regex: + description: |- + Regex is a Go regular expression that is optionally applied to the selected + value from the path. + properties: + pattern: + description: |- + Pattern can be left empty to simply replace the entire value with the + replacement. + type: string + replacement: + description: |- + Replacement is the string that the matched pattern is replaced with. It + can contain references to groups in the pattern by using \N. + type: string + type: object + template: + description: |- + TemplateExpression is a Go templated string that can make use of variables to + construct the resulting string. + properties: + template: + type: string + type: object + type: object + required: + - rewrite + type: object + x-kubernetes-map-type: atomic + template: + description: |- + Template is a Go templated string that can make use of variables to + construct the resulting string. + properties: + template: + type: string + type: object type: object + origin: + description: '"service" or "kcp"' + type: string required: - identifier - kind + - object - origin - - reference type: object type: array resource: diff --git a/docs/publish-resources.md b/docs/publish-resources.md index 6c1af28..91f6aef 100644 --- a/docs/publish-resources.md +++ b/docs/publish-resources.md @@ -226,25 +226,42 @@ Likewise it's possible for auxiliary resources having to be created by the user, the user has to provide credentials. To handle these cases, a `PublishedResource` can define multiple "related resources". Each related -resource currently represents exactly one object to synchronize between user workspace and service -cluster (i.e. you cannot express "sync all Secrets"). While the main published resource sync is -always workspace->service cluster, related resources can originate on either side and so either can -work as the source of truth. +resource represents usually one, but can be multiple objects to synchronize between user workspace +and service cluster. While the main published resource sync is always workspace->service cluster, +related resources can originate on either side and so either can work as the source of truth. At the moment, only `ConfigMaps` and `Secrets` are allowed related resource kinds. -For each related resource, the Sync Agent needs to be told their name/namespace. This is done by -selecting a field in the main resource (for a `Certificate` this would mean `spec.secretName`). Both -name and namespace need to be part of the main object (or be fixed values, like a hardcoded -`kube-system` namespace). +For each related resource, the Sync Agent needs to be told how to find the object on the origin side +and where to create it on the destination side. There are multiple options that you can choose from. -The path expressions for name and namespace are evaluated against the main object on either side -to determine their values. So if you had a `Certificate` in your workspace with -`spec.secretName = "my-cert"` and after syncing it down, the copy on the service cluster has a -rewritten/mutated `spec.secretName = "jk23h4wz47329rz2r72r92-cert"` (e.g. to prevent naming -collisions), the expression `spec.secretName` would yield `"my-cert"` for the name in the workspace -and `"jk...."` as the name on the service cluster. Once the object exists with that name on the -originating side, the Sync Agent will begin to sync it to the other side. +By default all related objects live in the same namespace as the primary object (their owner/parent). +If the primary object is cluster scoped, admins must configure additional rules to specify what +namespace the ConfigMap/Secret shall be read from and created in. + +Related resources are always optional. Even if references (see below) are used and their path +expression points to a non-existing field in the primary object (e.g. `spec.secretName` is configured, +but that field does not exist in Certificate object), this will simply be treated as "not _yet_ +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 1 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. + +Here's an example on how to use references to locate the related object. ```yaml apiVersion: syncagent.kcp.io/v1alpha1 @@ -277,10 +294,11 @@ spec: # there is no GVK projection for related resources kind: Secret - # configure where in the parent object we can find - # the name/namespace of the related resource (the child) - reference: - name: + # configure where in the parent object we can find the child object + object: + # Object can use either reference, labelSelector or expressions. In this + # example we use references. + reference: # This path is evaluated in both the local and remote objects, to figure out # the local and remote names for the related object. This saves us from having # to remember mutated fields before their mutation (similar to the last-known @@ -289,22 +307,115 @@ spec: # namespace part is optional; if not configured, # Sync Agent assumes the same namespace as the owning resource - # # namespace: - # path: spec.secretName - # regex: - # pattern: '...' - # replacement: '...' - # - # to inject static values, select a meaningless string value - # and leave the pattern empty - # + # reference: + # path: spec.secretName + # regex: + # pattern: '...' + # replacement: '...' +``` + +#### Label Selectors + +In some cases, the primary object does not have a link to its child/children objects. In these cases, +a label selector can be used. This allows to configure the labels that any related object must have +to be included. + +Notably, this allows for _multiple_ objects that are synced for a single configured related resource. +The sync agent will not prevent misconfigurations, so great care must be taken when configuring +selectors to not accidentally include too many objects. + +Additionally, it is assumed that + +* Primary objects synced from kcp to a service cluster will be renamed, to prevent naming collisions. +* The renamed objects on the service cluster might contain private, sensitive information that should + not be leaked into kcp workspaces. +* When there is no explicit name being requested (like by setting `spec.secretName`), it can be + assumed that the operator on the service cluster that is actually processing the primary object + will use the primary object's name (at least in parts) to construct the names of related objects, + for example a Certificate `yaddasupersecretyadda` might automatically get a Secret created named + `yaddasupersecretyadda-secret`. + +Since the name of the related object must not leak into a kcp workspace, admins who configure a +label selector also always have to provide a naming scheme for the copies of the related objects on +the destination side. + +Namespaces work the same as with references, i.e. by default the same namespace as the primary object +is assumed. However you can actually also use label selectors to find the origin _namespaces_ +dynamically. So you can configure two label selectors, and then agent will first use the namespace +selector to find all applicable namespaces, and then use the other label selector _in each of the +applicable namespaces_ to finally locate the related objects. How useful this is depends a lot on +how crazy the underlying operators on the service clusters are. + +Here is an example on how to use label selectors: + +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + resource: + kind: Certificate + apiGroup: cert-manager.io + version: v1 + + naming: + namespace: kube-system + name: "$remoteClusterName-$remoteNamespaceHash-$remoteNameHash" + + related: + - identifier: tls-secrets + + # "service" or "kcp" + origin: service + + # for now, only "Secret" and "ConfigMap" are supported; + # there is no GVK projection for related resources + kind: Secret + + # configure where in the parent object we can find the child object + object: + # A selector is a standard Kubernetes label selector, supporting + # matchLabels and matchExpressions. + selector: + matchLabels: + my-key: my-value + another: pair + + # You also need to provide rules on how objects found by this selector + # should be named on the destination side of the sync. + # Rewrites are either using regular expressions or templated strings, + # never both. + # The rewrite config is applied to each individual found object. + rewrite: + regex: + pattern: "foo-(.+)" + replacement: "bar-\\1" + + # or + template: + template: "{{ .Name }}-foo" + + # Like with references, the namespace can (or must) be configured explicitly. + # You do not need to also use label selectors here, you can mix and match + # freely. # namespace: - # path: metadata.uid - # regex: - # replacement: kube-system + # reference: + # path: metadata.namespace + # regex: + # pattern: '...' + # replacement: '...' ``` +#### Templates + +The third option to configure how to find/create related objects are templates. These are simple +Go template strings (like `{{ .Variable }}`) that allow to easily configure static values with a +sprinkling of dynamic values. + +This feature has not been fully implemented yet. + ## Examples ### Provide Certificates diff --git a/hack/update-codegen-sdk.sh b/hack/update-codegen-sdk.sh index 77de40d..c5badf3 100755 --- a/hack/update-codegen-sdk.sh +++ b/hack/update-codegen-sdk.sh @@ -28,7 +28,7 @@ SDK_PKG="$MODULE" APIS_PKG="$MODULE/apis" set -x -rm -rf -- "$SDK_DIR/{applyconfiguration,clientset,informers,listers}" +rm -rf -- $SDK_DIR/{applyconfiguration,clientset,informers,listers} go run k8s.io/code-generator/cmd/applyconfiguration-gen \ --go-header-file "$BOILERPLATE_HEADER" \ diff --git a/internal/controller/syncmanager/lifecycle/cluster.go b/internal/controller/syncmanager/lifecycle/cluster.go index 22462d0..6a4bff8 100644 --- a/internal/controller/syncmanager/lifecycle/cluster.go +++ b/internal/controller/syncmanager/lifecycle/cluster.go @@ -105,7 +105,7 @@ var apiRegex = regexp.MustCompile(`(/api/|/apis/)`) // generatePath formats the request path to target the specified cluster. 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 the originalPath already has cluster.Path() then the path was already modified and no change needed if strings.Contains(originalPath, workspacePath.RequestPath()) { return originalPath } diff --git a/internal/sync/state_store_test.go b/internal/sync/state_store_test.go index 0291808..6ea50bc 100644 --- a/internal/sync/state_store_test.go +++ b/internal/sync/state_store_test.go @@ -144,7 +144,7 @@ func TestStateStoreBasics(t *testing.T) { assertObjectsEqual(t, "RemoteThing", firstObject, result) /////////////////////////////////////// - // strip subresoures + // strip subresources thirdObject := newUnstructured(&dummyv1alpha1.ThingWithStatusSubresource{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/sync/syncer_related.go b/internal/sync/syncer_related.go index 77e3498..2dc86ea 100644 --- a/internal/sync/syncer_related.go +++ b/internal/sync/syncer_related.go @@ -18,8 +18,11 @@ package sync import ( "encoding/json" + "errors" "fmt" "regexp" + "slices" + "strings" "github.com/tidwall/gjson" "go.uber.org/zap" @@ -27,7 +30,9 @@ import ( "github.com/kcp-dev/api-syncagent/internal/mutation" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -58,195 +63,437 @@ type relatedObjectAnnotation struct { func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateStore ObjectStateStore, remote, local syncSide, relRes syncagentv1alpha1.RelatedResourceSpec) (requeue bool, err error) { // decide what direction to sync (local->remote vs. remote->local) var ( - source syncSide + origin syncSide dest syncSide ) if relRes.Origin == "service" { - source = local + origin = local dest = remote } else { - source = remote + origin = remote dest = local } - // to find the source related object, we first need to determine its name/namespace - sourceKey, err := resolveResourceReference(source.object, relRes.Reference) + // find the all objects on the origin side that match the given criteria + resolvedObjects, err := resolveRelatedResourceObjects(origin, dest, relRes) if err != nil { - return false, fmt.Errorf("failed to determine related object's source key: %w", err) + return false, fmt.Errorf("failed to get resolve origin objects: %w", err) } - // find the source related object - sourceObj := &unstructured.Unstructured{} - sourceObj.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 - sourceObj.SetKind(relRes.Kind) + // no objects were found yet, that's okay + if len(resolvedObjects) == 0 { + return false, nil + } - err = source.client.Get(source.ctx, *sourceKey, sourceObj) - if err != nil { - // the source object doesn't exist yet, so we can just stop - if apierrors.IsNotFound(err) { - return false, nil + slices.SortStableFunc(resolvedObjects, func(a, b resolvedObject) int { + aKey := ctrlruntimeclient.ObjectKeyFromObject(a.original).String() + bKey := ctrlruntimeclient.ObjectKeyFromObject(b.original).String() + + return strings.Compare(aKey, bKey) + }) + + // Synchronize objects the same way the parent object was synchronized. + for idx, resolved := range resolvedObjects { + destObject := &unstructured.Unstructured{} + destObject.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 + destObject.SetKind(relRes.Kind) + + if err = dest.client.Get(dest.ctx, resolved.destination, destObject); err != nil { + destObject = nil } - return false, fmt.Errorf("failed to get source object: %w", err) - } + sourceSide := syncSide{ + ctx: origin.ctx, + clusterName: origin.clusterName, + client: origin.client, + object: resolved.original, + } - // do the same to find the destination object - destKey, err := resolveResourceReference(dest.object, relRes.Reference) - if err != nil { - return false, fmt.Errorf("failed to determine related object's destination key: %w", err) + destSide := syncSide{ + ctx: dest.ctx, + clusterName: dest.clusterName, + client: dest.client, + object: destObject, + } + + syncer := objectSyncer{ + // Related objects within kcp are not labelled with the agent name because it's unnecessary. + // agentName: "", + // use the same state store as we used for the main resource, to keep everything contained + // in one place, on the service cluster side + stateStore: stateStore, + // how to create a new destination object + destCreator: func(source *unstructured.Unstructured) *unstructured.Unstructured { + dest := source.DeepCopy() + dest.SetName(resolved.destination.Name) + dest.SetNamespace(resolved.destination.Namespace) + + return dest + }, + // ConfigMaps and Secrets have no subresources + subresources: nil, + // only sync the status back if the object originates in kcp, + // as the service side should never have to rely on new status infos coming + // from the kcp side + syncStatusBack: relRes.Origin == "kcp", + // if the origin is on the remote side, we want to add a finalizer to make + // sure we can clean up properly + 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, + } + + req, err := syncer.Sync(log, sourceSide, destSide) + if err != nil { + return false, fmt.Errorf("failed to sync related object: %w", err) + } + + // Updating a related object should not immediately trigger a requeue, + // but only after all related objects are done. This is purely to not perform + // too many unnecessary requeues. + requeue = requeue || req + + // now that the related object was successfully synced, we can remember its details on the + // main object + if relRes.Origin == "service" { + // TODO: Improve this logic, the added index is just a hack until we find a better solution + // to let the user know about the related object (this annotation is not relevant for the + // syncing logic, it's purely for the end-user). + annotation := fmt.Sprintf("%s%s.%d", relatedObjectAnnotationPrefix, relRes.Identifier, idx) + + value, err := json.Marshal(relatedObjectAnnotation{ + Namespace: resolved.destination.Namespace, + Name: resolved.destination.Name, + APIVersion: "v1", // we only support ConfigMaps and Secrets + Kind: relRes.Kind, + }) + if err != nil { + return false, fmt.Errorf("failed to encode related object annotation: %w", err) + } + + annotations := remote.object.GetAnnotations() + existing := annotations[annotation] + + if existing != string(value) { + oldState := remote.object.DeepCopy() + + annotations[annotation] = string(value) + remote.object.SetAnnotations(annotations) + + log.Debug("Remembering related object in main object…") + if err := remote.client.Patch(remote.ctx, remote.object, ctrlruntimeclient.MergeFrom(oldState)); err != nil { + return false, fmt.Errorf("failed to update related data in remote object: %w", err) + } + + // requeue (since this updated the main object, we do actually want to + // requeue immediately because successive patches would fail anyway) + return true, nil + } + } } - destObj := &unstructured.Unstructured{} - destObj.SetAPIVersion("v1") - destObj.SetKind(relRes.Kind) + return requeue, nil +} + +// resolvedObject is the result of following the configuration of a related resources. It contains +// the original object (on the origin side of the related resource) and the target name to be used +// on the destination side of the sync. +type resolvedObject struct { + original *unstructured.Unstructured + destination types.NamespacedName +} + +func resolveRelatedResourceObjects(relatedOrigin, relatedDest syncSide, relRes syncagentv1alpha1.RelatedResourceSpec) ([]resolvedObject, error) { + // resolving the originNamespace first allows us to scope down any .List() calls later + originNamespace := relatedOrigin.object.GetNamespace() + destNamespace := relatedDest.object.GetNamespace() - if err := dest.client.Get(dest.ctx, *destKey, destObj); err != nil { - if !apierrors.IsNotFound(err) { - return false, fmt.Errorf("failed to get destination object: %w", err) + namespaceMap := map[string]string{ + originNamespace: destNamespace, + } + + if nsSpec := relRes.Object.Namespace; nsSpec != nil { + var err error + namespaceMap, err = resolveRelatedResourceOriginNamespaces(relatedOrigin, relatedDest, *nsSpec) + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace: %w", err) } - // signal to the syncer that the destination object doesn't exist - destObj = nil + if len(namespaceMap) == 0 { + return nil, nil + } + } else if originNamespace == "" { + return nil, errors.New("primary object is cluster-scoped and no source namespace configuration was provided") + } else if destNamespace == "" { + return nil, errors.New("primary object copy is cluster-scoped and no source namespace configuration was provided") } - // Synchronize objects the same way the parent object was synchronized. + // At this point we know all the namespaces in which can look for related objects. + // For all but the label selector-based specs, this map will have exactly 1 element, otherwise + // more. Empty maps are not possible at this point. + // The namespace map contains a mapping from origin side to destination side. + // Armed with this, we can now resolve the object names and thereby find all objects that match + // this related resource configuration. Again, for label selectors this can be multiple, + // otherwise at most 1. - sourceSide := syncSide{ - ctx: source.ctx, - clusterName: source.clusterName, - client: source.client, - object: sourceObj, - } - - destSide := syncSide{ - ctx: dest.ctx, - clusterName: dest.clusterName, - client: dest.client, - object: destObj, - } - - syncer := objectSyncer{ - // Related objects within kcp are not labelled with the agent name because it's unnecessary. - // agentName: "", - // use the same state store as we used for the main resource, to keep everything contained - // in one place, on the service cluster side - stateStore: stateStore, - // how to create a new destination object - destCreator: func(source *unstructured.Unstructured) *unstructured.Unstructured { - dest := source.DeepCopy() - dest.SetName(destKey.Name) - dest.SetNamespace(destKey.Namespace) - - return dest - }, - // ConfigMaps and Secrets have no subresources - subresources: nil, - // only sync the status back if the object originates in kcp, - // as the service side should never have to rely on new status infos coming - // from the kcp side - syncStatusBack: relRes.Origin == "kcp", - // if the origin is on the remote side, we want to add a finalizer to make - // sure we can clean up properly - 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) + objects, err := resolveRelatedResourceObjectsInNamespaces(relatedOrigin, relatedDest, relRes, relRes.Object.RelatedResourceObjectSpec, namespaceMap) if err != nil { - return false, fmt.Errorf("failed to sync related object: %w", err) + return nil, fmt.Errorf("failed to resolve objects: %w", err) } - if requeue { - return true, nil - } + return objects, nil +} - // now that the related object was successfully synced, we can remember its details on the - // main object - if relRes.Origin == "service" { - annotation := relatedObjectAnnotationPrefix + relRes.Identifier - - value, err := json.Marshal(relatedObjectAnnotation{ - Namespace: destKey.Namespace, - Name: destKey.Name, - APIVersion: "v1", // we only support ConfigMaps and Secrets - Kind: relRes.Kind, - }) +func resolveRelatedResourceOriginNamespaces(relatedOrigin, relatedDest syncSide, spec syncagentv1alpha1.RelatedResourceObjectSpec) (map[string]string, error) { + switch { + case spec.Reference != nil: + originNamespace, err := resolveObjectReference(relatedOrigin.object, *spec.Reference) + if err != nil { + return nil, err + } + + if originNamespace == "" { + return nil, nil + } + + destNamespace, err := resolveObjectReference(relatedDest.object, *spec.Reference) + if err != nil { + return nil, err + } + + if destNamespace == "" { + return nil, nil + } + + return map[string]string{ + originNamespace: destNamespace, + }, nil + + case spec.Selector != nil: + namespaces := &corev1.NamespaceList{} + + selector, err := metav1.LabelSelectorAsSelector(&spec.Selector.LabelSelector) if err != nil { - return false, fmt.Errorf("failed to encode related object annotation: %w", err) + return nil, fmt.Errorf("invalid selector configured: %w", err) } - annotations := remote.object.GetAnnotations() - existing := annotations[annotation] + opts := &ctrlruntimeclient.ListOptions{ + LabelSelector: selector, + } - if existing != string(value) { - oldState := remote.object.DeepCopy() + if err := relatedOrigin.client.List(relatedOrigin.ctx, namespaces, opts); err != nil { + return nil, fmt.Errorf("failed to evaluate label selector: %w", err) + } - annotations[annotation] = string(value) - remote.object.SetAnnotations(annotations) + namespaceMap := map[string]string{} + for _, namespace := range namespaces.Items { + name := namespace.Name - log.Debug("Remembering related object in main object…") - if err := remote.client.Patch(remote.ctx, remote.object, ctrlruntimeclient.MergeFrom(oldState)); err != nil { - return false, fmt.Errorf("failed to update related data in remote object: %w", err) + destinationName, err := applyRewrites(relatedOrigin, relatedDest, name, spec.Selector.Rewrite) + if err != nil { + return nil, fmt.Errorf("failed to rewrite origin namespace: %w", err) } - // requeue - return true, nil + namespaceMap[name] = destinationName } - } - return false, nil -} + return namespaceMap, nil -func resolveResourceReference(obj *unstructured.Unstructured, ref syncagentv1alpha1.RelatedResourceReference) (*ctrlruntimeclient.ObjectKey, error) { - jsonData, err := obj.MarshalJSON() - if err != nil { - return nil, err + case spec.Template != nil: + originValue, destValue, err := applyTemplateBothSides(relatedOrigin, relatedDest, *spec.Template) + if err != nil { + return nil, fmt.Errorf("failed to apply template: %w", err) + } + + if originValue == "" || destValue == "" { + return nil, nil + } + + return map[string]string{ + originValue: destValue, + }, nil + + default: + return nil, errors.New("invalid sourceSpec: no mechanism configured") } +} - name, err := resolveResourceLocator(string(jsonData), ref.Name) - if err != nil { - return nil, fmt.Errorf("cannot determine name: %w", err) +func resolveRelatedResourceObjectsInNamespaces(relatedOrigin, relatedDest syncSide, relRes syncagentv1alpha1.RelatedResourceSpec, spec syncagentv1alpha1.RelatedResourceObjectSpec, namespaceMap map[string]string) ([]resolvedObject, error) { + result := []resolvedObject{} + + for originNamespace, destNamespace := range namespaceMap { + nameMap, err := resolveRelatedResourceObjectsInNamespace(relatedOrigin, relatedDest, relRes, spec, originNamespace) + if err != nil { + return nil, fmt.Errorf("failed to find objects on origin side: %w", err) + } + + for originName, destName := range nameMap { + originObj := &unstructured.Unstructured{} + originObj.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 + originObj.SetKind(relRes.Kind) + + err = relatedOrigin.client.Get(relatedOrigin.ctx, types.NamespacedName{Name: originName, Namespace: originNamespace}, originObj) + if err != nil { + // this should rarely happen, only if an object was deleted in between the .List() call + // above and the .Get() call here. + if apierrors.IsNotFound(err) { + continue + } + + return nil, fmt.Errorf("failed to get origin object: %w", err) + } + + result = append(result, resolvedObject{ + original: originObj, + destination: types.NamespacedName{ + Namespace: destNamespace, + Name: destName, + }, + }) + } } - namespace := obj.GetNamespace() - if ref.Namespace != nil { - namespace, err = resolveResourceLocator(string(jsonData), *ref.Namespace) + return result, nil +} + +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) + if err != nil { + return nil, err + } + + if originName == "" { + return nil, nil + } + + destName, err := resolveObjectReference(relatedDest.object, *spec.Reference) + if err != nil { + return nil, err + } + + if destName == "" { + return nil, nil + } + + return map[string]string{ + originName: destName, + }, nil + + case spec.Selector != nil: + originObjects := &unstructured.UnstructuredList{} + originObjects.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 + originObjects.SetKind(relRes.Kind) + + selector, err := metav1.LabelSelectorAsSelector(&spec.Selector.LabelSelector) + if err != nil { + return nil, fmt.Errorf("invalid selector configured: %w", err) + } + + opts := &ctrlruntimeclient.ListOptions{ + LabelSelector: selector, + Namespace: namespace, + } + + if err := relatedOrigin.client.List(relatedOrigin.ctx, originObjects, opts); err != nil { + return nil, fmt.Errorf("failed to select origin objects based on label selector: %w", err) + } + + nameMap := map[string]string{} + for _, originObject := range originObjects.Items { + name := originObject.GetName() + + destinationName, err := applyRewrites(relatedOrigin, relatedDest, name, spec.Selector.Rewrite) + if err != nil { + return nil, fmt.Errorf("failed to rewrite origin name: %w", err) + } + + nameMap[name] = destinationName + } + + return nameMap, nil + + case spec.Template != nil: + originValue, destValue, err := applyTemplateBothSides(relatedOrigin, relatedDest, *spec.Template) if err != nil { - return nil, fmt.Errorf("cannot determine namespace: %w", err) + return nil, fmt.Errorf("failed to apply template: %w", err) } + + if originValue == "" || destValue == "" { + return nil, nil + } + + return map[string]string{ + originValue: destValue, + }, nil + + default: + return nil, errors.New("invalid objectSpec: no mechanism configured") + } +} + +func resolveObjectReference(object *unstructured.Unstructured, ref syncagentv1alpha1.RelatedResourceObjectReference) (string, error) { + data, err := object.MarshalJSON() + if err != nil { + return "", err } - return &types.NamespacedName{ - Namespace: namespace, - Name: name, - }, nil + return resolveReference(data, ref) } -func resolveResourceLocator(jsonData string, loc syncagentv1alpha1.ResourceLocator) (string, error) { - gval := gjson.Get(jsonData, loc.Path) +func resolveReference(jsonData []byte, ref syncagentv1alpha1.RelatedResourceObjectReference) (string, error) { + gval := gjson.Get(string(jsonData), ref.Path) if !gval.Exists() { - return "", fmt.Errorf("cannot find %s in document", loc.Path) + return "", fmt.Errorf("cannot find %s in document", ref.Path) } - if re := loc.Regex; re != nil { - if re.Pattern == "" { - return re.Replacement, nil - } + // this does apply some coalescing, like turning numbers into strings + strVal := gval.String() - expr, err := regexp.Compile(re.Pattern) + if re := ref.Regex; re != nil { + var err error + + strVal, err = applyRegularExpression(strVal, *re) if err != nil { - return "", fmt.Errorf("invalid pattern %q: %w", re.Pattern, err) + return "", err } + } - // this does apply some coalescing, like turning numbers into strings - strVal := gval.String() + return strVal, nil +} + +func applyRewrites(relatedOrigin, relatedDest syncSide, value string, rewrite syncagentv1alpha1.RelatedResourceSelectorRewrite) (string, error) { + switch { + case rewrite.Regex != nil: + return applyRegularExpression(value, *rewrite.Regex) + case rewrite.Template != nil: + return applyTemplate(relatedOrigin, relatedDest, *rewrite.Template, value) + default: + return "", errors.New("invalid rewrite: no mechanism configured") + } +} - return expr.ReplaceAllString(strVal, re.Replacement), nil +func applyRegularExpression(value string, re syncagentv1alpha1.RegularExpression) (string, error) { + if re.Pattern == "" { + return re.Replacement, nil } - return gval.String(), nil + expr, err := regexp.Compile(re.Pattern) + if err != nil { + return "", fmt.Errorf("invalid pattern %q: %w", re.Pattern, err) + } + + return expr.ReplaceAllString(value, re.Replacement), nil +} + +func applyTemplate(relatedOrigin, relatedDest syncSide, tpl syncagentv1alpha1.TemplateExpression, value string) (string, error) { + return "", errors.New("not yet implemented") +} + +func applyTemplateBothSides(relatedOrigin, relatedDest syncSide, tpl syncagentv1alpha1.TemplateExpression) (originValue, destValue string, err error) { + return "", "", errors.New("not yet implemented") } diff --git a/sdk/apis/syncagent/v1alpha1/published_resource.go b/sdk/apis/syncagent/v1alpha1/published_resource.go index 1abe61a..e68f1a3 100644 --- a/sdk/apis/syncagent/v1alpha1/published_resource.go +++ b/sdk/apis/syncagent/v1alpha1/published_resource.go @@ -171,30 +171,90 @@ type RelatedResourceSpec struct { // ConfigMap or Secret Kind string `json:"kind"` - Reference RelatedResourceReference `json:"reference"` + // Object describes how the related resource can be found on the origin side + // and where it is to supposed to be created on the destination side. + Object RelatedResourceObject `json:"object"` // Mutation configures optional transformation rules for the related resource. // Status mutations are only performed when the related resource originates in kcp. Mutation *ResourceMutationSpec `json:"mutation,omitempty"` } -type RelatedResourceReference struct { - Name ResourceLocator `json:"name"` - Namespace *ResourceLocator `json:"namespace,omitempty"` +// RelatedResourceSource configures how the related resource can be found on the origin side +// and where it is to supposed to be created on the destination side. +type RelatedResourceObject struct { + RelatedResourceObjectSpec `json:",inline"` + + // Namespace configures in what namespace the related object resides in. If + // not specified, the same namespace as the main object is assumed. If the + // main object is cluster-scoped, this field is required and an error will be + // raised during syncing if the field is not specified. + Namespace *RelatedResourceObjectSpec `json:"namespace,omitempty"` +} + +// RelatedResourceObjectSpec configures different ways an object can be located. +// All fields are mutually exclusive. +type RelatedResourceObjectSpec struct { + // Selector is a label selector that is useful if no reference is in the + // main resource (i.e. if the related object links back to its parent, instead + // of the parent pointing to the related object). + Selector *RelatedResourceObjectSelector `json:"selector,omitempty"` + // Reference points to a field inside the main object. This reference is + // evaluated on both source and destination sides to find the related object. + Reference *RelatedResourceObjectReference `json:"reference,omitempty"` + // Template is a Go templated string that can make use of variables to + // construct the resulting string. + Template *TemplateExpression `json:"template,omitempty"` } -type ResourceLocator struct { - Path string `json:"path"` - Regex *RegexResourceLocator `json:"regex,omitempty"` +// RelatedResourceObjectReference describes a path expression that is evaluated inside +// a JSON-marshalled Kubernetes object, yielding a string when evaluated. +type RelatedResourceObjectReference struct { + // Path is a simplified JSONPath expression like "metadata.name". A reference + // must always select at least _something_ in the object, even if the value + // is discarded by the regular expression. + Path string `json:"path"` + // Regex is a Go regular expression that is optionally applied to the selected + // value from the path. + Regex *RegularExpression `json:"regex,omitempty"` } -type RegexResourceLocator struct { +// RelatedResourceSelector is a dedicated struct in case we need additional options +// for evaluating the label selector. + +// RelatedResourceObjectSelector describes how to locate a related object based on +// labels. This is useful if the main resource has no and cannot construct a +// reference to the related object because its name/namespace might be randomized. +type RelatedResourceObjectSelector struct { + metav1.LabelSelector `json:",inline"` + + Rewrite RelatedResourceSelectorRewrite `json:"rewrite"` +} + +type RelatedResourceSelectorRewrite struct { + // Regex is a Go regular expression that is optionally applied to the selected + // value from the path. + Regex *RegularExpression `json:"regex,omitempty"` + Template *TemplateExpression `json:"template,omitempty"` +} + +// RegularExpression models a Go regular expression string replacement. See +// https://pkg.go.dev/regexp/syntax for more information on the syntax. +type RegularExpression struct { // Pattern can be left empty to simply replace the entire value with the // replacement. - Pattern string `json:"pattern,omitempty"` + Pattern string `json:"pattern,omitempty"` + // Replacement is the string that the matched pattern is replaced with. It + // can contain references to groups in the pattern by using \N. Replacement string `json:"replacement,omitempty"` } +// TemplateExpression is a Go templated string that can make use of variables to +// construct the resulting string. +type TemplateExpression struct { + Template string `json:"template,omitempty"` +} + // SourceResourceDescriptor and ResourceProjection are very similar, but as we do not // want to burden service clusters with validation webhooks, it's easier to split them // into 2 structs here and rely on the schema for validation. diff --git a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go index 6ee4a74..8235c67 100644 --- a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go +++ b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go @@ -143,37 +143,129 @@ func (in *PublishedResourceStatus) DeepCopy() *PublishedResourceStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RegexResourceLocator) DeepCopyInto(out *RegexResourceLocator) { +func (in *RegularExpression) DeepCopyInto(out *RegularExpression) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegexResourceLocator. -func (in *RegexResourceLocator) DeepCopy() *RegexResourceLocator { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegularExpression. +func (in *RegularExpression) DeepCopy() *RegularExpression { if in == nil { return nil } - out := new(RegexResourceLocator) + out := new(RegularExpression) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RelatedResourceReference) DeepCopyInto(out *RelatedResourceReference) { +func (in *RelatedResourceObject) DeepCopyInto(out *RelatedResourceObject) { *out = *in - in.Name.DeepCopyInto(&out.Name) + in.RelatedResourceObjectSpec.DeepCopyInto(&out.RelatedResourceObjectSpec) if in.Namespace != nil { in, out := &in.Namespace, &out.Namespace - *out = new(ResourceLocator) + *out = new(RelatedResourceObjectSpec) (*in).DeepCopyInto(*out) } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceReference. -func (in *RelatedResourceReference) DeepCopy() *RelatedResourceReference { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceObject. +func (in *RelatedResourceObject) DeepCopy() *RelatedResourceObject { if in == nil { return nil } - out := new(RelatedResourceReference) + out := new(RelatedResourceObject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelatedResourceObjectReference) DeepCopyInto(out *RelatedResourceObjectReference) { + *out = *in + if in.Regex != nil { + in, out := &in.Regex, &out.Regex + *out = new(RegularExpression) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceObjectReference. +func (in *RelatedResourceObjectReference) DeepCopy() *RelatedResourceObjectReference { + if in == nil { + return nil + } + out := new(RelatedResourceObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelatedResourceObjectSelector) DeepCopyInto(out *RelatedResourceObjectSelector) { + *out = *in + in.LabelSelector.DeepCopyInto(&out.LabelSelector) + in.Rewrite.DeepCopyInto(&out.Rewrite) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceObjectSelector. +func (in *RelatedResourceObjectSelector) DeepCopy() *RelatedResourceObjectSelector { + if in == nil { + return nil + } + out := new(RelatedResourceObjectSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelatedResourceObjectSpec) DeepCopyInto(out *RelatedResourceObjectSpec) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(RelatedResourceObjectSelector) + (*in).DeepCopyInto(*out) + } + if in.Reference != nil { + in, out := &in.Reference, &out.Reference + *out = new(RelatedResourceObjectReference) + (*in).DeepCopyInto(*out) + } + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(TemplateExpression) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceObjectSpec. +func (in *RelatedResourceObjectSpec) DeepCopy() *RelatedResourceObjectSpec { + if in == nil { + return nil + } + out := new(RelatedResourceObjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelatedResourceSelectorRewrite) DeepCopyInto(out *RelatedResourceSelectorRewrite) { + *out = *in + if in.Regex != nil { + in, out := &in.Regex, &out.Regex + *out = new(RegularExpression) + **out = **in + } + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(TemplateExpression) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceSelectorRewrite. +func (in *RelatedResourceSelectorRewrite) DeepCopy() *RelatedResourceSelectorRewrite { + if in == nil { + return nil + } + out := new(RelatedResourceSelectorRewrite) in.DeepCopyInto(out) return out } @@ -181,7 +273,7 @@ func (in *RelatedResourceReference) DeepCopy() *RelatedResourceReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RelatedResourceSpec) DeepCopyInto(out *RelatedResourceSpec) { *out = *in - in.Reference.DeepCopyInto(&out.Reference) + in.Object.DeepCopyInto(&out.Object) if in.Mutation != nil { in, out := &in.Mutation, &out.Mutation *out = new(ResourceMutationSpec) @@ -239,26 +331,6 @@ func (in *ResourceFilter) DeepCopy() *ResourceFilter { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceLocator) DeepCopyInto(out *ResourceLocator) { - *out = *in - if in.Regex != nil { - in, out := &in.Regex, &out.Regex - *out = new(RegexResourceLocator) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceLocator. -func (in *ResourceLocator) DeepCopy() *ResourceLocator { - if in == nil { - return nil - } - out := new(ResourceLocator) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceMutation) DeepCopyInto(out *ResourceMutation) { *out = *in @@ -402,3 +474,18 @@ func (in *SourceResourceDescriptor) DeepCopy() *SourceResourceDescriptor { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateExpression) DeepCopyInto(out *TemplateExpression) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateExpression. +func (in *TemplateExpression) DeepCopy() *TemplateExpression { + if in == nil { + return nil + } + out := new(TemplateExpression) + in.DeepCopyInto(out) + return out +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/regexresourcelocator.go b/sdk/applyconfiguration/syncagent/v1alpha1/regularexpression.go similarity index 67% rename from sdk/applyconfiguration/syncagent/v1alpha1/regexresourcelocator.go rename to sdk/applyconfiguration/syncagent/v1alpha1/regularexpression.go index e4ebcca..6bad6d2 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/regexresourcelocator.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/regularexpression.go @@ -18,23 +18,23 @@ limitations under the License. package v1alpha1 -// RegexResourceLocatorApplyConfiguration represents a declarative configuration of the RegexResourceLocator type for use +// RegularExpressionApplyConfiguration represents a declarative configuration of the RegularExpression type for use // with apply. -type RegexResourceLocatorApplyConfiguration struct { +type RegularExpressionApplyConfiguration struct { Pattern *string `json:"pattern,omitempty"` Replacement *string `json:"replacement,omitempty"` } -// RegexResourceLocatorApplyConfiguration constructs a declarative configuration of the RegexResourceLocator type for use with +// RegularExpressionApplyConfiguration constructs a declarative configuration of the RegularExpression type for use with // apply. -func RegexResourceLocator() *RegexResourceLocatorApplyConfiguration { - return &RegexResourceLocatorApplyConfiguration{} +func RegularExpression() *RegularExpressionApplyConfiguration { + return &RegularExpressionApplyConfiguration{} } // WithPattern sets the Pattern 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 Pattern field is set to the value of the last call. -func (b *RegexResourceLocatorApplyConfiguration) WithPattern(value string) *RegexResourceLocatorApplyConfiguration { +func (b *RegularExpressionApplyConfiguration) WithPattern(value string) *RegularExpressionApplyConfiguration { b.Pattern = &value return b } @@ -42,7 +42,7 @@ func (b *RegexResourceLocatorApplyConfiguration) WithPattern(value string) *Rege // WithReplacement sets the Replacement 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 Replacement field is set to the value of the last call. -func (b *RegexResourceLocatorApplyConfiguration) WithReplacement(value string) *RegexResourceLocatorApplyConfiguration { +func (b *RegularExpressionApplyConfiguration) WithReplacement(value string) *RegularExpressionApplyConfiguration { b.Replacement = &value return b } diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobject.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobject.go new file mode 100644 index 0000000..af7d72b --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobject.go @@ -0,0 +1,64 @@ +/* +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// RelatedResourceObjectApplyConfiguration represents a declarative configuration of the RelatedResourceObject type for use +// with apply. +type RelatedResourceObjectApplyConfiguration struct { + RelatedResourceObjectSpecApplyConfiguration `json:",inline"` + Namespace *RelatedResourceObjectSpecApplyConfiguration `json:"namespace,omitempty"` +} + +// RelatedResourceObjectApplyConfiguration constructs a declarative configuration of the RelatedResourceObject type for use with +// apply. +func RelatedResourceObject() *RelatedResourceObjectApplyConfiguration { + return &RelatedResourceObjectApplyConfiguration{} +} + +// WithSelector sets the Selector 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 Selector field is set to the value of the last call. +func (b *RelatedResourceObjectApplyConfiguration) WithSelector(value *RelatedResourceObjectSelectorApplyConfiguration) *RelatedResourceObjectApplyConfiguration { + b.Selector = value + return b +} + +// WithReference sets the Reference 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 Reference field is set to the value of the last call. +func (b *RelatedResourceObjectApplyConfiguration) WithReference(value *RelatedResourceObjectReferenceApplyConfiguration) *RelatedResourceObjectApplyConfiguration { + b.Reference = value + return b +} + +// WithTemplate sets the Template 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 Template field is set to the value of the last call. +func (b *RelatedResourceObjectApplyConfiguration) WithTemplate(value *TemplateExpressionApplyConfiguration) *RelatedResourceObjectApplyConfiguration { + b.Template = value + return b +} + +// WithNamespace sets the Namespace 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 Namespace field is set to the value of the last call. +func (b *RelatedResourceObjectApplyConfiguration) WithNamespace(value *RelatedResourceObjectSpecApplyConfiguration) *RelatedResourceObjectApplyConfiguration { + b.Namespace = value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/resourcelocator.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectreference.go similarity index 57% rename from sdk/applyconfiguration/syncagent/v1alpha1/resourcelocator.go rename to sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectreference.go index 61e9ce4..fc6159b 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/resourcelocator.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectreference.go @@ -18,23 +18,23 @@ limitations under the License. package v1alpha1 -// ResourceLocatorApplyConfiguration represents a declarative configuration of the ResourceLocator type for use +// RelatedResourceObjectReferenceApplyConfiguration represents a declarative configuration of the RelatedResourceObjectReference type for use // with apply. -type ResourceLocatorApplyConfiguration struct { - Path *string `json:"path,omitempty"` - Regex *RegexResourceLocatorApplyConfiguration `json:"regex,omitempty"` +type RelatedResourceObjectReferenceApplyConfiguration struct { + Path *string `json:"path,omitempty"` + Regex *RegularExpressionApplyConfiguration `json:"regex,omitempty"` } -// ResourceLocatorApplyConfiguration constructs a declarative configuration of the ResourceLocator type for use with +// RelatedResourceObjectReferenceApplyConfiguration constructs a declarative configuration of the RelatedResourceObjectReference type for use with // apply. -func ResourceLocator() *ResourceLocatorApplyConfiguration { - return &ResourceLocatorApplyConfiguration{} +func RelatedResourceObjectReference() *RelatedResourceObjectReferenceApplyConfiguration { + return &RelatedResourceObjectReferenceApplyConfiguration{} } // WithPath sets the Path 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 Path field is set to the value of the last call. -func (b *ResourceLocatorApplyConfiguration) WithPath(value string) *ResourceLocatorApplyConfiguration { +func (b *RelatedResourceObjectReferenceApplyConfiguration) WithPath(value string) *RelatedResourceObjectReferenceApplyConfiguration { b.Path = &value return b } @@ -42,7 +42,7 @@ func (b *ResourceLocatorApplyConfiguration) WithPath(value string) *ResourceLoca // WithRegex sets the Regex 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 Regex field is set to the value of the last call. -func (b *ResourceLocatorApplyConfiguration) WithRegex(value *RegexResourceLocatorApplyConfiguration) *ResourceLocatorApplyConfiguration { +func (b *RelatedResourceObjectReferenceApplyConfiguration) WithRegex(value *RegularExpressionApplyConfiguration) *RelatedResourceObjectReferenceApplyConfiguration { b.Regex = value return b } diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectselector.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectselector.go new file mode 100644 index 0000000..6ce78ff --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectselector.go @@ -0,0 +1,71 @@ +/* +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// RelatedResourceObjectSelectorApplyConfiguration represents a declarative configuration of the RelatedResourceObjectSelector type for use +// with apply. +type RelatedResourceObjectSelectorApplyConfiguration struct { + v1.LabelSelectorApplyConfiguration `json:",inline"` + Rewrite *RelatedResourceSelectorRewriteApplyConfiguration `json:"rewrite,omitempty"` +} + +// RelatedResourceObjectSelectorApplyConfiguration constructs a declarative configuration of the RelatedResourceObjectSelector type for use with +// apply. +func RelatedResourceObjectSelector() *RelatedResourceObjectSelectorApplyConfiguration { + return &RelatedResourceObjectSelectorApplyConfiguration{} +} + +// WithMatchLabels puts the entries into the MatchLabels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the MatchLabels field, +// overwriting an existing map entries in MatchLabels field with the same key. +func (b *RelatedResourceObjectSelectorApplyConfiguration) WithMatchLabels(entries map[string]string) *RelatedResourceObjectSelectorApplyConfiguration { + if b.MatchLabels == nil && len(entries) > 0 { + b.MatchLabels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.MatchLabels[k] = v + } + return b +} + +// WithMatchExpressions adds the given value to the MatchExpressions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the MatchExpressions field. +func (b *RelatedResourceObjectSelectorApplyConfiguration) WithMatchExpressions(values ...*v1.LabelSelectorRequirementApplyConfiguration) *RelatedResourceObjectSelectorApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithMatchExpressions") + } + b.MatchExpressions = append(b.MatchExpressions, *values[i]) + } + return b +} + +// WithRewrite sets the Rewrite 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 Rewrite field is set to the value of the last call. +func (b *RelatedResourceObjectSelectorApplyConfiguration) WithRewrite(value *RelatedResourceSelectorRewriteApplyConfiguration) *RelatedResourceObjectSelectorApplyConfiguration { + b.Rewrite = value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectspec.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectspec.go new file mode 100644 index 0000000..ccdfb3f --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceobjectspec.go @@ -0,0 +1,57 @@ +/* +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// RelatedResourceObjectSpecApplyConfiguration represents a declarative configuration of the RelatedResourceObjectSpec type for use +// with apply. +type RelatedResourceObjectSpecApplyConfiguration struct { + Selector *RelatedResourceObjectSelectorApplyConfiguration `json:"selector,omitempty"` + Reference *RelatedResourceObjectReferenceApplyConfiguration `json:"reference,omitempty"` + Template *TemplateExpressionApplyConfiguration `json:"template,omitempty"` +} + +// RelatedResourceObjectSpecApplyConfiguration constructs a declarative configuration of the RelatedResourceObjectSpec type for use with +// apply. +func RelatedResourceObjectSpec() *RelatedResourceObjectSpecApplyConfiguration { + return &RelatedResourceObjectSpecApplyConfiguration{} +} + +// WithSelector sets the Selector 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 Selector field is set to the value of the last call. +func (b *RelatedResourceObjectSpecApplyConfiguration) WithSelector(value *RelatedResourceObjectSelectorApplyConfiguration) *RelatedResourceObjectSpecApplyConfiguration { + b.Selector = value + return b +} + +// WithReference sets the Reference 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 Reference field is set to the value of the last call. +func (b *RelatedResourceObjectSpecApplyConfiguration) WithReference(value *RelatedResourceObjectReferenceApplyConfiguration) *RelatedResourceObjectSpecApplyConfiguration { + b.Reference = value + return b +} + +// WithTemplate sets the Template 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 Template field is set to the value of the last call. +func (b *RelatedResourceObjectSpecApplyConfiguration) WithTemplate(value *TemplateExpressionApplyConfiguration) *RelatedResourceObjectSpecApplyConfiguration { + b.Template = value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcereference.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcereference.go deleted file mode 100644 index ebdcb0c..0000000 --- a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcereference.go +++ /dev/null @@ -1,48 +0,0 @@ -/* -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. -*/ - -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// RelatedResourceReferenceApplyConfiguration represents a declarative configuration of the RelatedResourceReference type for use -// with apply. -type RelatedResourceReferenceApplyConfiguration struct { - Name *ResourceLocatorApplyConfiguration `json:"name,omitempty"` - Namespace *ResourceLocatorApplyConfiguration `json:"namespace,omitempty"` -} - -// RelatedResourceReferenceApplyConfiguration constructs a declarative configuration of the RelatedResourceReference type for use with -// apply. -func RelatedResourceReference() *RelatedResourceReferenceApplyConfiguration { - return &RelatedResourceReferenceApplyConfiguration{} -} - -// WithName sets the Name 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 Name field is set to the value of the last call. -func (b *RelatedResourceReferenceApplyConfiguration) WithName(value *ResourceLocatorApplyConfiguration) *RelatedResourceReferenceApplyConfiguration { - b.Name = value - return b -} - -// WithNamespace sets the Namespace 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 Namespace field is set to the value of the last call. -func (b *RelatedResourceReferenceApplyConfiguration) WithNamespace(value *ResourceLocatorApplyConfiguration) *RelatedResourceReferenceApplyConfiguration { - b.Namespace = value - return b -} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceselectorrewrite.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceselectorrewrite.go new file mode 100644 index 0000000..4b27e7c --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceselectorrewrite.go @@ -0,0 +1,48 @@ +/* +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// RelatedResourceSelectorRewriteApplyConfiguration represents a declarative configuration of the RelatedResourceSelectorRewrite type for use +// with apply. +type RelatedResourceSelectorRewriteApplyConfiguration struct { + Regex *RegularExpressionApplyConfiguration `json:"regex,omitempty"` + Template *TemplateExpressionApplyConfiguration `json:"template,omitempty"` +} + +// RelatedResourceSelectorRewriteApplyConfiguration constructs a declarative configuration of the RelatedResourceSelectorRewrite type for use with +// apply. +func RelatedResourceSelectorRewrite() *RelatedResourceSelectorRewriteApplyConfiguration { + return &RelatedResourceSelectorRewriteApplyConfiguration{} +} + +// WithRegex sets the Regex 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 Regex field is set to the value of the last call. +func (b *RelatedResourceSelectorRewriteApplyConfiguration) WithRegex(value *RegularExpressionApplyConfiguration) *RelatedResourceSelectorRewriteApplyConfiguration { + b.Regex = value + return b +} + +// WithTemplate sets the Template 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 Template field is set to the value of the last call. +func (b *RelatedResourceSelectorRewriteApplyConfiguration) WithTemplate(value *TemplateExpressionApplyConfiguration) *RelatedResourceSelectorRewriteApplyConfiguration { + b.Template = value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go index 3ac0143..08173b2 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go @@ -21,11 +21,11 @@ package v1alpha1 // RelatedResourceSpecApplyConfiguration represents a declarative configuration of the RelatedResourceSpec type for use // with apply. type RelatedResourceSpecApplyConfiguration struct { - Identifier *string `json:"identifier,omitempty"` - Origin *string `json:"origin,omitempty"` - Kind *string `json:"kind,omitempty"` - Reference *RelatedResourceReferenceApplyConfiguration `json:"reference,omitempty"` - Mutation *ResourceMutationSpecApplyConfiguration `json:"mutation,omitempty"` + Identifier *string `json:"identifier,omitempty"` + Origin *string `json:"origin,omitempty"` + Kind *string `json:"kind,omitempty"` + Object *RelatedResourceObjectApplyConfiguration `json:"object,omitempty"` + Mutation *ResourceMutationSpecApplyConfiguration `json:"mutation,omitempty"` } // RelatedResourceSpecApplyConfiguration constructs a declarative configuration of the RelatedResourceSpec type for use with @@ -58,11 +58,11 @@ func (b *RelatedResourceSpecApplyConfiguration) WithKind(value string) *RelatedR return b } -// WithReference sets the Reference field in the declarative configuration to the given value +// WithObject sets the Object 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 Reference field is set to the value of the last call. -func (b *RelatedResourceSpecApplyConfiguration) WithReference(value *RelatedResourceReferenceApplyConfiguration) *RelatedResourceSpecApplyConfiguration { - b.Reference = value +// If called multiple times, the Object field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithObject(value *RelatedResourceObjectApplyConfiguration) *RelatedResourceSpecApplyConfiguration { + b.Object = value return b } diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/templateexpression.go b/sdk/applyconfiguration/syncagent/v1alpha1/templateexpression.go new file mode 100644 index 0000000..1464d74 --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/templateexpression.go @@ -0,0 +1,39 @@ +/* +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// TemplateExpressionApplyConfiguration represents a declarative configuration of the TemplateExpression type for use +// with apply. +type TemplateExpressionApplyConfiguration struct { + Template *string `json:"template,omitempty"` +} + +// TemplateExpressionApplyConfiguration constructs a declarative configuration of the TemplateExpression type for use with +// apply. +func TemplateExpression() *TemplateExpressionApplyConfiguration { + return &TemplateExpressionApplyConfiguration{} +} + +// WithTemplate sets the Template 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 Template field is set to the value of the last call. +func (b *TemplateExpressionApplyConfiguration) WithTemplate(value string) *TemplateExpressionApplyConfiguration { + b.Template = &value + return b +} diff --git a/sdk/applyconfiguration/utils.go b/sdk/applyconfiguration/utils.go index 2b1822b..b2ca26d 100644 --- a/sdk/applyconfiguration/utils.go +++ b/sdk/applyconfiguration/utils.go @@ -40,18 +40,24 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &syncagentv1alpha1.PublishedResourceSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("PublishedResourceStatus"): return &syncagentv1alpha1.PublishedResourceStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("RegexResourceLocator"): - return &syncagentv1alpha1.RegexResourceLocatorApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceReference"): - return &syncagentv1alpha1.RelatedResourceReferenceApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RegularExpression"): + return &syncagentv1alpha1.RegularExpressionApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceObject"): + return &syncagentv1alpha1.RelatedResourceObjectApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceObjectReference"): + return &syncagentv1alpha1.RelatedResourceObjectReferenceApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceObjectSelector"): + return &syncagentv1alpha1.RelatedResourceObjectSelectorApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceObjectSpec"): + return &syncagentv1alpha1.RelatedResourceObjectSpecApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceSelectorRewrite"): + return &syncagentv1alpha1.RelatedResourceSelectorRewriteApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceSpec"): return &syncagentv1alpha1.RelatedResourceSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceDeleteMutation"): return &syncagentv1alpha1.ResourceDeleteMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceFilter"): return &syncagentv1alpha1.ResourceFilterApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("ResourceLocator"): - return &syncagentv1alpha1.ResourceLocatorApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceMutation"): return &syncagentv1alpha1.ResourceMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceMutationSpec"): @@ -66,6 +72,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &syncagentv1alpha1.ResourceTemplateMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("SourceResourceDescriptor"): return &syncagentv1alpha1.SourceResourceDescriptorApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("TemplateExpression"): + return &syncagentv1alpha1.TemplateExpressionApplyConfiguration{} } return nil diff --git a/test/crds/backup.go b/test/crds/backup.go new file mode 100644 index 0000000..2087547 --- /dev/null +++ b/test/crds/backup.go @@ -0,0 +1,33 @@ +/* +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 crds + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Backup struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BackupSpec `json:"spec"` +} + +type BackupSpec struct { + Source string `json:"source"` + Destination string `json:"destination"` +} diff --git a/test/crds/crontab.go b/test/crds/crontab.go new file mode 100644 index 0000000..8b34a44 --- /dev/null +++ b/test/crds/crontab.go @@ -0,0 +1,34 @@ +/* +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 crds + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Crontab struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CrontabSpec `json:"spec"` +} + +type CrontabSpec struct { + CronSpec string `json:"cronSpec"` + Image string `json:"image"` + Replicas int `json:"replicas"` +} diff --git a/test/e2e/apiexport/apiexport_test.go b/test/e2e/apiexport/apiexport_test.go index cee6d5e..7265b80 100644 --- a/test/e2e/apiexport/apiexport_test.go +++ b/test/e2e/apiexport/apiexport_test.go @@ -133,12 +133,16 @@ func TestPermissionsClaims(t *testing.T) { Identifier: "super-secret", Origin: "kcp", Kind: "Secret", - Reference: syncagentv1alpha1.RelatedResourceReference{ - Name: syncagentv1alpha1.ResourceLocator{ - Path: "spec.test.name", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.test.name", + }, }, - Namespace: &syncagentv1alpha1.ResourceLocator{ - Path: "spec.test.namespace", + Namespace: &syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.test.namespace", + }, }, }, }, @@ -146,12 +150,16 @@ func TestPermissionsClaims(t *testing.T) { Identifier: "other-super-secret", Origin: "service", Kind: "Secret", - Reference: syncagentv1alpha1.RelatedResourceReference{ - Name: syncagentv1alpha1.ResourceLocator{ - Path: "spec.otherTest.name", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.otherTest.name", + }, }, - Namespace: &syncagentv1alpha1.ResourceLocator{ - Path: "spec.otherTest.namespace", + Namespace: &syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.otherTest.namespace", + }, }, }, }, @@ -162,12 +170,16 @@ func TestPermissionsClaims(t *testing.T) { Identifier: "config", Origin: "kcp", Kind: "ConfigMap", - Reference: syncagentv1alpha1.RelatedResourceReference{ - Name: syncagentv1alpha1.ResourceLocator{ - Path: "spec.secretTest.name", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.secretTest.name", + }, }, - Namespace: &syncagentv1alpha1.ResourceLocator{ - Path: "spec.secretTest.namespace", + Namespace: &syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.secretTest.namespace", + }, }, }, }, @@ -284,12 +296,16 @@ func TestExistingPermissionsClaimsAreKept(t *testing.T) { Identifier: "super-secret", Origin: "kcp", Kind: "Secret", - Reference: syncagentv1alpha1.RelatedResourceReference{ - Name: syncagentv1alpha1.ResourceLocator{ - Path: "spec.test.name", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.test.name", + }, }, - Namespace: &syncagentv1alpha1.ResourceLocator{ - Path: "spec.test.namespace", + Namespace: &syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "spec.test.namespace", + }, }, }, }, diff --git a/test/e2e/sync/related_test.go b/test/e2e/sync/related_test.go index 9a22eaa..0a38d7e 100644 --- a/test/e2e/sync/related_test.go +++ b/test/e2e/sync/related_test.go @@ -31,164 +31,551 @@ import ( "github.com/kcp-dev/api-syncagent/internal/test/diff" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + "github.com/kcp-dev/api-syncagent/test/crds" "github.com/kcp-dev/api-syncagent/test/utils" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" ctrlruntime "sigs.k8s.io/controller-runtime" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/kontext" ) -func TestSyncSecretBackToKcp(t *testing.T) { - const ( - apiExportName = "kcp.example.com" - orgWorkspace = "sync-related-secret-to-kcp" - ) +func TestSyncRelatedObjects(t *testing.T) { + const apiExportName = "kcp.example.com" - ctx := context.Background() ctrlruntime.SetLogger(logr.Discard()) - // setup a test environment in kcp - orgKubconfig := utils.CreateOrganization(t, ctx, orgWorkspace, apiExportName) + 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 + mainResource crds.Crontab + // the original related object (will automatically be created on either the + // kcp or service side, depending on the relatedConfig above) + sourceRelatedObject corev1.Secret - // start a service cluster - envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ - "test/crds/crontab.yaml", - }) + // expectation: this is how the copy of the related object should look + // like after the sync has completed + expectedSyncedRelatedObject corev1.Secret + // expectation: how the original primary object should have been updated + // (not the primary object's copy, but the source) + // + // not yet implemented + // expectedUpdatedMainObject crds.Crontab + }{ + { + name: "sync referenced Secret up from service cluster to kcp", + workspace: "sync-referenced-secret-up", + mainResource: crds.Crontab{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{ + CronSpec: "* * *", + Image: "ubuntu:latest", + }, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "credentials", + Origin: "service", + Kind: "Secret", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "metadata.name", // irrelevant + Regex: &syncagentv1alpha1.RegularExpression{ + Replacement: "my-credentials", + }, + }, + }, + }, + }, + sourceRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, - // publish Crontabs and Backups - t.Logf("Publishing CRDs…") - prCrontabs := &syncagentv1alpha1.PublishedResource{ - ObjectMeta: metav1.ObjectMeta{ - Name: "publish-crontabs", + expectedSyncedRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, }, - Spec: syncagentv1alpha1.PublishedResourceSpec{ - Resource: syncagentv1alpha1.SourceResourceDescriptor{ - APIGroup: "example.com", - Version: "v1", - Kind: "CronTab", + + ////////////////////////////////////////////////////////////////////////////////////////////// + + { + name: "sync referenced Secret down from kcp to the service cluster", + workspace: "sync-referenced-secret-down", + mainResource: crds.Crontab{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{ + CronSpec: "* * *", + Image: "ubuntu:latest", + }, }, - // These rules make finding the local object easier, but should not be used in production. - Naming: &syncagentv1alpha1.ResourceNaming{ - Name: "$remoteName", - Namespace: "synced-$remoteNamespace", + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "credentials", + Origin: "kcp", + Kind: "Secret", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + Path: "metadata.name", // irrelevant + Regex: &syncagentv1alpha1.RegularExpression{ + Replacement: "my-credentials", + }, + }, + }, + }, + }, + sourceRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + + expectedSyncedRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "synced-default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + + ////////////////////////////////////////////////////////////////////////////////////////////// + + // { + // name: "sync referenced Secret up into a new namespace", + // workspace: "sync-referenced-secret-up-namespace", + // mainResource: crds.Crontab{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-crontab", + // Namespace: "default", + // }, + // Spec: crds.CrontabSpec{ + // CronSpec: "* * *", + // Image: "ubuntu:latest", + // }, + // }, + // relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + // Identifier: "credentials", + // Origin: "service", + // Kind: "Secret", + // Object: syncagentv1alpha1.RelatedResourceObject{ + // RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // }, + // Destination: syncagentv1alpha1.RelatedResourceDestination{ + // RelatedResourceDestinationSpec: syncagentv1alpha1.RelatedResourceDestinationSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // Namespace: &syncagentv1alpha1.RelatedResourceDestinationSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "new-namespace", + // }, + // }, + // }, + // }, + // }, + // sourceRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "synced-default", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + + // expectedSyncedRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "new-namespace", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + // }, + + // ////////////////////////////////////////////////////////////////////////////////////////////// + + // { + // name: "sync referenced Secret down into a new namespace", + // workspace: "sync-referenced-secret-down-namespace", + // mainResource: crds.Crontab{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-crontab", + // Namespace: "default", + // }, + // Spec: crds.CrontabSpec{ + // CronSpec: "* * *", + // Image: "ubuntu:latest", + // }, + // }, + // relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + // Identifier: "credentials", + // Origin: "kcp", + // Kind: "Secret", + // Object: syncagentv1alpha1.RelatedResourceObject{ + // RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // }, + // Destination: syncagentv1alpha1.RelatedResourceDestination{ + // RelatedResourceDestinationSpec: syncagentv1alpha1.RelatedResourceDestinationSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // Namespace: &syncagentv1alpha1.RelatedResourceDestinationSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "new-namespace", + // }, + // }, + // }, + // }, + // }, + // sourceRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "default", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + + // expectedSyncedRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "new-namespace", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + // }, + + // ////////////////////////////////////////////////////////////////////////////////////////////// + + // { + // name: "sync referenced Secret up from a foreign namespace", + // workspace: "sync-referenced-secret-up-foreign-namespace", + // mainResource: crds.Crontab{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-crontab", + // Namespace: "default", + // }, + // Spec: crds.CrontabSpec{ + // CronSpec: "* * *", + // Image: "ubuntu:latest", + // }, + // }, + // relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + // Identifier: "credentials", + // Origin: "service", + // Kind: "Secret", + // Object: syncagentv1alpha1.RelatedResourceObject{ + // RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // Namespace: &syncagentv1alpha1.RelatedResourceObjectSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "other-namespace", + // }, + // }, + // }, + // }, + // Destination: syncagentv1alpha1.RelatedResourceDestination{ + // RelatedResourceDestinationSpec: syncagentv1alpha1.RelatedResourceDestinationSpec{ + // Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ + // Path: "metadata.name", // irrelevant + // Regex: &syncagentv1alpha1.RegularExpression{ + // Replacement: "my-credentials", + // }, + // }, + // }, + // }, + // }, + // sourceRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "other-namespace", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + + // expectedSyncedRelatedObject: corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "my-credentials", + // Namespace: "default", + // }, + // Data: map[string][]byte{ + // "password": []byte("hunter2"), + // }, + // Type: corev1.SecretTypeOpaque, + // }, + // }, + + ////////////////////////////////////////////////////////////////////////////////////////////// + + { + name: "find Secret based on label selector", + workspace: "sync-selected-secret-up", + mainResource: crds.Crontab{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{ + CronSpec: "* * *", + Image: "ubuntu:latest", + }, }, - Related: []syncagentv1alpha1.RelatedResourceSpec{{ + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ Identifier: "credentials", Origin: "service", Kind: "Secret", - Reference: syncagentv1alpha1.RelatedResourceReference{ - Name: syncagentv1alpha1.ResourceLocator{ - Path: "metadata.name", // irrelevant - Regex: &syncagentv1alpha1.RegexResourceLocator{ - Replacement: "my-credentials", + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Selector: &syncagentv1alpha1.RelatedResourceObjectSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "find": "me", + }, + }, + Rewrite: syncagentv1alpha1.RelatedResourceSelectorRewrite{ + // TODO: Use template instead of regex once that is implemented. + Regex: &syncagentv1alpha1.RegularExpression{ + Replacement: "my-credentials", + }, + }, }, }, }, - }}, + }, + sourceRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unknown-name", + Namespace: "synced-default", + Labels: map[string]string{ + "find": "me", + }, + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + + expectedSyncedRelatedObject: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "default", + Labels: map[string]string{ + "find": "me", + }, + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, }, } - if err := envtestClient.Create(ctx, prCrontabs); err != nil { - t.Fatalf("Failed to create PublishedResource: %v", err) - } + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + ctx := context.Background() - // 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 Crontab object in a team workspace - t.Log("Creating CronTab in kcp…") - crontab := yamlToUnstructured(t, ` -apiVersion: kcp.example.com/v1 -kind: CronTab -metadata: - namespace: default - name: my-crontab -spec: - cronSpec: '* * *' - image: ubuntu:latest -`) - - if err := kcpClient.Create(teamCtx, crontab); err != nil { - t.Fatalf("Failed to create CronTab in kcp: %v", err) - } + // setup a test environment in kcp + orgKubconfig := utils.CreateOrganization(t, ctx, testcase.workspace, apiExportName) - // fake operator: create a credential Secret - t.Log("Creating credential Secret in service cluster…") - namespace := &corev1.Namespace{} - namespace.Name = "synced-default" + // start a service cluster + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ + "test/crds/crontab.yaml", + }) - if err := envtestClient.Create(ctx, namespace); err != nil { - t.Fatalf("Failed to create namespace in kcp: %v", err) - } + // 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", + }, + Related: []syncagentv1alpha1.RelatedResourceSpec{testcase.relatedConfig}, + }, + } - credentials := &corev1.Secret{} - credentials.Name = "my-credentials" - credentials.Namespace = namespace.Name - credentials.Labels = map[string]string{ - "hello": "world", - } - credentials.Data = map[string][]byte{ - "password": []byte("hunter2"), - } + if err := envtestClient.Create(ctx, prCrontabs); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } - if err := envtestClient.Create(ctx, credentials); err != nil { - t.Fatalf("Failed to create Secret in service cluster: %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 for the agent to sync the object down into the service cluster and - // the Secret back up to kcp - t.Logf("Wait for CronTab/Secret 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: "synced-default", Name: "my-crontab"} - return envtestClient.Get(ctx, copyKey, copy) == nil, nil - }) - if err != nil { - t.Fatalf("Failed to wait for CronTab to be synced down: %v", err) - } + // wait until the API is available + teamCtx := kontext.WithCluster(ctx, logicalcluster.Name(fmt.Sprintf("root:%s:team-1", testcase.workspace))) + kcpClient := utils.GetKcpAdminClusterClient(t) + utils.WaitForBoundAPI(t, teamCtx, kcpClient, schema.GroupVersionResource{ + Group: apiExportName, + Version: "v1", + Resource: "crontabs", + }) - copySecret := &corev1.Secret{} + // create a Crontab object in a team workspace + t.Log("Creating CronTab in kcp…") - err = wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 30*time.Second, false, func(ctx context.Context) (done bool, err error) { - copyKey := types.NamespacedName{Namespace: "default", Name: "my-credentials"} - return kcpClient.Get(teamCtx, copyKey, copySecret) == nil, nil - }) - if err != nil { - t.Fatalf("Failed to wait for Secret to be synced up: %v", err) - } + crontab := utils.ToUnstructured(t, &testcase.mainResource) + crontab.SetAPIVersion("kcp.example.com/v1") + crontab.SetKind("CronTab") - // 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/") - }) + if err := kcpClient.Create(teamCtx, crontab); err != nil { + t.Fatalf("Failed to create CronTab in kcp: %v", err) + } - if changes := diff.ObjectDiff(credentials.Labels, copySecret.Labels); changes != "" { - t.Errorf("Secret in kcp has unexpected labels:\n%s", changes) - } + // fake operator: create a credential Secret + t.Logf("Creating credential Secret on the %s side…", testcase.relatedConfig.Origin) + + originClient := envtestClient + originContext := ctx + destClient := kcpClient + destContext := teamCtx - delete(copySecret.Annotations, "kcp.io/cluster") - if len(copySecret.Annotations) == 0 { - copySecret.Annotations = nil + if testcase.relatedConfig.Origin == "kcp" { + originClient, destClient = destClient, originClient + originContext, destContext = destContext, originContext + } + + ensureNamespace(t, originContext, originClient, testcase.sourceRelatedObject.Namespace) + + if err := originClient.Create(originContext, &testcase.sourceRelatedObject); err != nil { + t.Fatalf("Failed to create Secret: %v", err) + } + + // wait for the agent to do its magic + t.Log("Wait for Secret to be synced…") + 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 + }) + 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) + } + }) } +} + +func ensureNamespace(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, name string) { + namespace := &corev1.Namespace{} + namespace.Name = name - if changes := diff.ObjectDiff(credentials.Annotations, copySecret.Annotations); changes != "" { - t.Errorf("Secret in kcp has unexpected annotations:\n%s", changes) + if err := client.Create(ctx, namespace); err != nil { + if !apierrors.IsAlreadyExists(err) { + t.Fatalf("Failed to create namespace %s in kcp: %v", name, err) + } } } diff --git a/test/utils/utils.go b/test/utils/utils.go index 4811c4e..f48a7f1 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -31,6 +31,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/scale/scheme" "k8s.io/client-go/tools/clientcmd" @@ -174,3 +175,12 @@ func CreateKcpAgentKubeconfig(t *testing.T, path string) string { return kubeconfigFile.Name() } + +func ToUnstructured(t *testing.T, obj any) *unstructured.Unstructured { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatalf("Failed to convert object to unstructurd: %v", err) + } + + return &unstructured.Unstructured{Object: raw} +}