diff --git a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml index 4048990..cd88b07 100644 --- a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml +++ b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml @@ -349,8 +349,23 @@ spec: type: object type: object related: + description: |- + Related configures additional resources that semantically belong to the synced + resource, like a Secret containing generated credentials. Related objects are + synced along the main resource. items: + description: |- + RelatedResourceSpec describes a single related resource, which might point to + any number of actual Kubernetes objects. + + (in the following rule, group is optional becaue core/v1 is represented by group="") + group is included here because when an identityHash is used, core/v1 cannot possible be targetted properties: + group: + description: |- + Group is the API group of the related resource. This should be left blank for resources + in the core API group. + type: string identifier: description: |- Identifier is a unique name for this related resource. The name must be unique within one @@ -358,8 +373,20 @@ spec: related resource. Common names are "connection-details" or "credentials". The identifier must be an alphanumeric string. type: string + identityHash: + description: |- + IdentityHash is the identity hash of a kcp APIExport, in case the given Kind is + provided by an APIExport and not Kube-native. + type: string kind: - description: ConfigMap or Secret + description: |- + Kind is the object kind of the related resource (for example "Secret"). + + Deprecated: Use "Resource" instead. This field is limited to "ConfigMap" and "Secret" and will + be removed in the future. Kind and Resource cannot be specified at the same time. + enum: + - ConfigMap + - Secret type: string mutation: description: |- @@ -686,12 +713,45 @@ spec: - service - kcp type: string + projection: + description: |- + Projection is used to change the GVK of a related resource on the opposite side of + its origin. + All fields in the projection are optional. If a field is set, it will overwrite + that field in the GVK. + properties: + group: + description: The API group, for example "myservice.example.com". Leave empty to not modify the API group. + type: string + resource: + description: The resource name, for example "databases". Leave empty to not modify the resource. + type: string + version: + description: The API version, for example "v1beta1". Leave empty to not modify the version. + type: string + type: object + resource: + description: Resource is the name of the related resource (for example "secrets"). + type: string + version: + description: |- + Version is the API version of the related resource. This can be left blank to automatically + use the preferred version. + type: string required: - identifier - - kind - object - origin type: object + x-kubernetes-validations: + - message: must specify either kind (deprecated) or group, version, resource + rule: has(self.kind) != (has(self.version) || has(self.resource)) + - message: resource and version must be configured together or not at all + rule: has(self.resource) == has(self.version) + - message: configuring a group also requires a version and resource + rule: '!has(self.group) || (has(self.resource) && has(self.version))' + - message: identity hashes can only be used with GVRs + rule: '!has(self.identityHash) || (has(self.group) && has(self.version) && has(self.resource))' type: array resource: description: |- @@ -723,6 +783,22 @@ spec: - apiGroup - kind type: object + synchronization: + description: Synchronization allows to configure how the syncagent processes this resource. + properties: + enabled: + description: |- + Enabled can be used to toggle the synchronization as a whole. When set to + false, the syncagent will only copy the CRD and include it in the APIExport, + but not will attempt to synchronize objects of this resource from the kcp + workspaces to the provider. + Synchronization must be disabled for resources that are used as related + resources for other PublishedResources. Otherwise the syncagent would + potentially loop and never finish processing an object. + type: boolean + required: + - enabled + type: object required: - resource type: object diff --git a/docs/content/faq.md b/docs/content/faq.md index c9c1a09..21d6f79 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -32,10 +32,17 @@ schema from the `APIExport`. ## Does the Sync Agent handle permission claims? -Only those required for its own operation. If you configure a namespaced resource to sync, it will -automatically add a claim for `namespaces` in kcp, plus it will add either `configmaps` or `secrets` -if related resources are configured in a `PublishedResource`. But you cannot specify additional -permissions claims. +Only those required for its own operation. The syncagent will add the following permission claims +to the APIExport it manages: + +* `events` (always) +* `namespaces` (always) +* `core.kcp.io/logicalclusters` (if `enableWorkspacePaths` is set in a `PublishedResource`) +* any resource used as related resources (most often this means `configmaps` or `secrets`, but could + be any resource) + +The syncagent will always overwrite the entire list of permission claims, i.e. you cannot have custom +claims in an APIExport managed by the api-syncagent. ## I am seeing errors in the agent logs, what's going on? diff --git a/docs/content/publish-resources/.pages b/docs/content/publish-resources/.pages index e342023..2c52c65 100644 --- a/docs/content/publish-resources/.pages +++ b/docs/content/publish-resources/.pages @@ -1,5 +1,6 @@ nav: - index.md + - related-resources.md - templating.md - api-lifecycle.md - technical-details.md diff --git a/docs/content/publish-resources/index.md b/docs/content/publish-resources/index.md index 3fc105a..9d2ca15 100644 --- a/docs/content/publish-resources/index.md +++ b/docs/content/publish-resources/index.md @@ -35,7 +35,7 @@ usually originate on the service cluster and could be for example connection det by Crossplane, but could also originate in the user workspace and just be additional, auxiliary resources that need to be synced down to the service cluster. -### `PublishedResource` +## `PublishedResource` In its simplest form (which is rarely practical) a `PublishedResource` looks like this: @@ -287,333 +287,7 @@ resource represents usually one, but can be multiple objects to synchronize betw 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 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. - -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 (more precisely, it follows the [gjson syntax](https://github.com/tidwall/gjson?tab=readme-ov-file#path-syntax)) -that are evaluated on both sides of the synchronization. You configure a single path expression -(like `spec.secretName`) and the sync agent will evaluate it in the original primary object (in kcp) -and again in the copied primary object (on the service cluster). Since the primary object has already -been mutated, the `spec.secretName` is already rewritten/adjusted to work on the service cluster -(for example it was changed from `my-secret` to `jk23h4wz47329rz2r72r92-secret` on the service -cluster side). By doing it this way, admins only have to think about mutations and rewrites once -(when configuring the primary object in the PublishedResource) and the path will yield 2 ready to -use values (`my-secret` and the computed value). - -References can either return a single scalar (strings or integers that will be auto-converted to a -string) (like in `spec.secretName`) or a list of strings/numbers (like `spec.users.#.name`). A -reference must return the same number of items on both the local and remote object, otherwise the -agent will not be able to map local related names to remote related names correctly. - -A regular expression can be configured to be applied to each found value (i.e. if the reference returns -a list of values, the regular expression is applied to each individual value). - -Here's an example on how to use references to locate the related object. - -{% raw %} -```yaml -apiVersion: syncagent.kcp.io/v1alpha1 -kind: PublishedResource -metadata: - name: publish-certmanager-certs -spec: - resource: - kind: Certificate - apiGroup: cert-manager.io - versions: [v1] - - naming: - # this is where our CA and Issuer live in this example - namespace: kube-system - # need to adjust it to prevent collisions (normally clustername is the namespace) - name: "{{ .ClusterName }}-{{ .Object.metadata.namespace | sha3short }}-{{ .Object.metadata.name | sha3short }}" - - related: - - # unique name for this related resource. The name must be unique within - # one PublishedResource and is the key by which consumers (end users) - # can identify and consume the related resource. Common names are - # "connection-details" or "credentials". - identifier: tls-secret - - # "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: - # Object can use either reference, labelSelector or template. 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 - # annotation). - path: spec.secretName - - # namespace part is optional; if not configured, - # Sync Agent assumes the same namespace as the owning resource - # namespace: - # reference: - # path: spec.secretName - # regex: - # pattern: '...' - # replacement: '...' -``` -{% endraw %} - -#### Templates - -Similar to references, [Go templates](https://pkg.go.dev/text/template) can also be used to determine -the names of related objects on both sides of the sync. In fact, templates can be thought of as more -powerful references since they allow for minimal logic to be embedded in them. Templates also do not -necessarily have to select a value from the object (like a reference does), but can use any kind of -logic to determine the names. - -Like references, templates can also only be used to select a single object per related resource. - -A template gets the following data injected into it: - -```go -type localObjectNamingContext struct { - // Side is set to either one of the possible origin values to indicate for - // which cluster the template is currently being evaluated for. - Side syncagentv1alpha1.RelatedResourceOrigin - // Object is the primary object belonging to the related object. Since related - // object templates are evaluated twice (once for the origin side and once - // for the destination side), object is the primary object on the side the - // template is evaluated for. - Object map[string]any - // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") - // of the kcp workspace that the synchronization is currently processing. This - // value is set for both evaluations, regardless of side. - ClusterName logicalcluster.Name - // ClusterPath is the workspace path (e.g. "root:customer:projectx"). This - // value is set for both evaluations, regardless of side. - ClusterPath logicalcluster.Path -} -``` - -In the simplest form, a template can replace a reference: - -* reference: `.spec.secretName` -* Go template: {% raw %}`{{ .Object.spec.secretName }}`{% endraw %} - -Just like with references, the configured template is evaluated twice, once for each side of the -synchronization. You can use the `Side` variable to allow for fully customized names on each side: - -{% raw %} -```yaml -spec: - ... - related: - - identifier: tls-secret - # ..omitting other fields.. - object: - template: - template: `{{ if eq .Side "kcp" }}name-in-kcp{{ else }}name-on-service-cluster{{ end }}` -``` -{% endraw %} - -See [Templating](templating.md) for more information on how to use templates in PublishedResources. - -#### 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 peculiar the underlying operators on the service clusters are. - -Here is an example on how to use label selectors: - -{% raw %} -```yaml -apiVersion: syncagent.kcp.io/v1alpha1 -kind: PublishedResource -metadata: - name: publish-certmanager-certs -spec: - resource: - kind: Certificate - apiGroup: cert-manager.io - versions: [v1] - - naming: - namespace: kube-system - name: "{{ .ClusterName }}-{{ .Object.metadata.namespace | sha3short }}-{{ .Object.metadata.name | sha3short }}" - - 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 - # Within matchLabels, keys and values are treated as Go templates. - # In this example, since the Secret originates on the service cluster - # (see "origin" above), we use LocalObject to determine the value - # for the selector. In case the object was heavily mutated during the - # sync, this will give access to the mutated values on the service - # cluster side. - '{{ shasum "test" }}': '{{ .LocalObject.spec.username }}' - - # You also need to provide rules on how objects found by this selector - # should be named on the destination side of the sync. You can choose - # to define a rewrite rule that keeps the original name from the origin - # side, but this may leak undesirable internals to the users. - # 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: "{{ .Value }}-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: - # reference: - # path: metadata.namespace - # regex: - # pattern: '...' - # replacement: '...' -``` -{% endraw %} - -There are two possible usages of Go templates when using label selectors. See [Templating](templating.md) -for more information on how to use templates in PublishedResources in general. - -##### Selector Templates - -Each template rendered as part of a `matchLabels` selector gets the following data injected: - -```go -type relatedObjectLabelContext struct { - // LocalObject is the primary object copy on the local side of the sync - // (i.e. on the service cluster). - LocalObject map[string]any - // RemoteObject is the primary object original, in kcp. - RemoteObject map[string]any - // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") - // of the kcp workspace that the synchronization is currently processing - // (where the remote object exists). - ClusterName logicalcluster.Name - // ClusterPath is the workspace path (e.g. "root:customer:projectx"). - ClusterPath logicalcluster.Path -} -``` - -Note that in contrast to the `template` way of selecting objects, the templates here in the label -selector are only evaluated once, on the origin side of the sync. The names of the destination side -are determined using the rewrite mechanism (which might also be a Go template, see next section). - -##### Rewrite Rules - -Each found related object on the origin side needs to have its own name on the destination side. To -map from the origin to the destination side, regular expressions (see example snippet) or Go -templates can be used. - -If a template is configured, it is evaluated once for every found related object. The template gets -the following data injected into it: - -```go -type relatedObjectLabelRewriteContext struct { - // Value is either the a found namespace name (when a label selector was - // used to select the source namespaces for related objects) or the name of - // a found object (when a label selector was used to find objects). In the - // former case, the template should return the new namespace to use on the - // destination side, in the latter case it should return the new object name - // to use on the destination side. - Value string - // When a rewrite is used to rewrite object names, RelatedObject is the - // original related object (found on the origin side). This enables you to - // ignore the given Value entirely and just select anything from the object - // itself. - // RelatedObject is nil when the rewrite is performed for a namespace. - RelatedObject map[string]any - // LocalObject is the primary object copy on the local side of the sync - // (i.e. on the service cluster). - LocalObject map[string]any - // RemoteObject is the primary object original, in kcp. - RemoteObject map[string]any - // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") - // of the kcp workspace that the synchronization is currently processing - // (where the remote object exists). - ClusterName logicalcluster.Name - // ClusterPath is the workspace path (e.g. "root:customer:projectx"). - ClusterPath logicalcluster.Path -} -``` - -Regarding `Value`: The agent allows to individually configure rules for finding object _names_ and -object _namespaces_. Often the namespace is not configured because the related objects live in the -same namespace as their owning, primary object. - -When a label selector is configured to find namespaces, the rewrite template will be evaluated once -for each found namespace. In this case the `.Value` is the name of the found namespace. Remember, the -template's job is to map the found namespace to the new namespace on the destination side of the sync. - -Once the namespaces have been determined, the agent will look for matching objects in each namespace -individually. For each namespace it will again follow the configured source, may it be a selector, -template or reference. If again a label selector is used, it will be applied in each namespace and -the configured rewrite rule will be evaluated once per found object. In this case, `.Value` is the -name of found object. +More information is available in the [related resources guide](./related-resources.md). ## Examples @@ -650,9 +324,11 @@ spec: name: "{{ .ClusterName }}-{{ .Object.metadata.namespace | sha3short }}-{{ .Object.metadata.name | sha3short }}" related: - - origin: service # service or kcp - kind: Secret # for now, only "Secret" and "ConfigMap" are supported; - # there is no GVK projection for related resources + - identifier: tls-secrets + origin: service # service or kcp + group: "" + version: v1 + resource: secrets # configure where in the parent object we can find # the name/namespace of the related resource (the child) diff --git a/docs/content/publish-resources/related-resources.md b/docs/content/publish-resources/related-resources.md new file mode 100644 index 0000000..3c0a975 --- /dev/null +++ b/docs/content/publish-resources/related-resources.md @@ -0,0 +1,573 @@ +# Related Resources + +This document describes the configuration of related resources. These are additional resources, +configured inside a `PublishedResource`, that are synced along a primary object. + +## Background + +The processing of resources on the service cluster often leads to additional resources being +created, like a `Secret` for each cert-manager `Certificate` or a connection detail secret created +by Crossplane. These need to be made available to the user in their workspaces. + +Likewise it's possible for auxiliary resources having to be created by the user, for example when +the user has to provide credentials. + +To handle these cases, a `PublishedResource` can define multiple "related resources". Each related +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. + +Related resources can be + +* core resources available in basically any Kubernetes cluster, like `ConfigMaps` or `Secrets`, +* resources contained in the same `APIExport` that is managed by the syncagent or +* resources contained in other `APIExports` not managed by the syncagent (in this case, the + identity hash of the target `APIExport` must be configured) + +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. + +By default all related objects live in the same namespace as the primary object (their owner/parent). +If the primary object is cluster scoped, a namespace must be configured to specify which namespaces +the resource shall be read from and written to. + +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 a Certificate object), this will simply be treated as "not _yet_ +existing" and not create an error. + +## Permission Claims + +Regardless of resource selection method chosen, each related resource will automatically become its +own permission claim in the `APIExport` and will need to be accepted by each consumer. The syncagent +assumes it has permissions and will report an error if it cannot access a related resources. + +## Resource Selection + +This section describes how the Kubernetes resource (like `secrets`) used as a related resource can +be configured. In all scenarios, the `group`, `version` and `resource` (GVR) will have to be set +correctly, maybe even the `identityHash`. + +The configured GVR will be used on both sides of the sync, i.e. in kcp and on the service cluster, +unless additional projection rules (like are possible for the primary published resource) are +configured. Using these projections, one could for example turn a `ConfigMap` into a `Secret` (as +an academic example). + +### Core Resources + +Core resources are built-in to every Kubernetes cluster and exist for example in the API group +`core/v1`, but RBAC could technically also be considered a core resource in `rbac.authorization.k8s.io`. +"Core" here really only means that the resource exists in every kcp workspace and on the service +cluster without additional CRDs/APIExports. + +For these built-in resources, all you need to do is set their GVR: + +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + # ... + + related: + - # ... + + # sync regular ol' Kubernetes Secrets + group: "" # for core/v1, leave this empty, else put the API group + version: v1 + resource: secrets + + # alternatively, for example + # group: "rbac.authorization.k8s.io" + # version: v1 + # resource: roles +``` + +In older version of syncagent, an alternative syntax was supported, where only the `kind` was +specified since only `ConfigMap` and `Secret` were allowed. This configuration (the `kind` field in +its entirety) is deprecated and will be removed in a future syncagent release. + +### Owned Resources + +"Owned" refers to all resources that are defined in the syncagent-managed `APIExport`. For configuring +related resources, it does not matter where the resource in the `APIExport` came from (whether the +agent added it based on a `PublishedResource` or whether it already existed in the `APIExport`). + +!!! note + If you use a `PublishedResource` to make a Kubernetes resource available in the `APIExport`, and + then intend to use this resource as a related resource, you must ensure that you set + `spec.synchronization.enabled=false` or else the syncagent could end up in a loop, synchronizing + the same objects back and forth. See further down for more information. + +Since your `APIExport` "owns" the related resource, configuration is identical to how core resources +are configured: Simply provide the GVR: + +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + # ... + + related: + - # ... + + # assume that this PublishedResource belongs to the APIExport that provides + # a resourceSchema for UserDatabases in myapi.example.com/v1 + group: "myapi.example.com" + version: v1beta1 + resource: userdatabases +``` + +The configured resource here will be used on both sides of the sync, as with any other related +resource type. If the primary resource in the `PublishedResource` makes use of projection to change +the API groups or versions between kcp and the service cluster, you will most likely also want to +project the GVR of related resources. + +Also take note that if you use projection for related resources, the original GVR of the related +resource is used on the origin side, and the projected GVR value is used on the destination side. So +depending on your `origin` value, you either to project or "undo" the projection. + +Take this example, where we use two `PublishedResources` to make two CRDs available in kcp, then use +one of them as a related resource of the other. + +```yaml +# This PublishedResource is only meant as a helper to "ship" our CRD to kcp and +# project its GVR into a nice-looking, public API group. No UserDatabase object +# should ever be synced on its own, they all always belong to a Queue object. + +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-userdatabases +spec: + # describe the CRD on the service cluster + resource: + kind: UserDatabase + apiGroup: poc.corporate.example.dev + versions: [v1alpha1] + + # make it look more professional in kcp + projection: + apiGroup: api.initech.example.com + versions: + v1alpha1: v2 + + # IMPORTANT: Disable synchronizing the resource, otherwise objects that are + # meant to be related to Queue objects would suddenly be treated as standalone + # objects meant to be synced on their own. + synchronization: + enabled: false + +--- + +# This PR is our focus, users are meant to create Queue objects for which we +# then on the provide side provision, among other things, a UserDatabase object +# that is then meant to be synced back to the user. + +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-queues +spec: + # describe the CRD on the service cluster + resource: + kind: Queue + apiGroup: poc.corporate.example.dev + versions: [v1alpha1] + + # make it look more professional in kcp + projection: + apiGroup: api.initech.example.com + versions: + v1alpha1: v2 + + related: + - id: userdb + origin: service + + # Since the UserDatabase originates on the service cluster, we must use + # the GVR that is present on the service cluster, not the projected one. + group: poc.corporate.example.dev + version: v1alpha1 + resource: userdatabases + + # However, since we do use projection, we must make sure the syncagent + # also projects this GVR properly. So here we provide the same rules as + # for the primary resource: + projection: + group: api.initech.example.com + version: v2 + + # --- OR, if the related resource originates in kcp --- + + # origin: kcp + # + # group: api.initech.example.com + # version: v2 + # resource: userdatabases + # + # # revert the projection + # projection: + # group: poc.corporate.example.dev + # version: v1alpha1 +``` + +### Foreign Resources + +Any non-built-in and non-owned resource is considered a "foreign" resource and are provided by +other `APIExports` in kcp. To refer to them as related resources, the `identityHash` of the owning +`APIExport` must be configured as well. This hash can be obtained from an `APIExport`'s +`status.identityHash` field. + +It is in this case up to you to make sure the necessary resource is available on the service cluster. +Since the GVR can be projected, you can use a custom name on the service cluster if needed. + +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + # ... + + related: + - # ... + + group: "othercorp.example.com" + version: v2beta1 + resource: things + # hash taken from the other APIExport's status.identityHash field + identityHash: 423ui5gfr8f237g49238hrfg7wtref132g82zv43u21 +``` + +## Object Selection + +Object selection is the configuration to tell the syncagent which Kubernetes objects it should +consider belonging to the primary object. The idea here is that you always tell the relationship +starting from the primary object (like if the primary object had a `secretName` field). + +In the synchronization loop, the syncagent will always start with the primary object and then find +the related objects for it. It never, for example, looks at a `Secret` on the service cluster and +has to determine what `Certificate` it belongs to. As of now, the syncagent does not even watch +related resources at all, because it currently cannot efficiently answer exactly that question. +Instead the agent, for any given `Secret`, would have to list _all_ primary objects and for each of +them, resolve all matching related objects and then finally check if the `Secret` is among them. +See [issue #118](https://github.com/kcp-dev/api-syncagent/issues/118) for more information. + +### References + +A reference is a JSONPath-like expression (more precisely, it follows the [gjson syntax](https://github.com/tidwall/gjson?tab=readme-ov-file#path-syntax)) +that are evaluated on both sides of the synchronization. You configure a single path expression +(like `spec.secretName`) and the sync agent will evaluate it in the original primary object (in kcp) +and again in the copied primary object (on the service cluster). Since the primary object has already +been mutated, the `spec.secretName` is already rewritten/adjusted to work on the service cluster +(for example it was changed from `my-secret` to `jk23h4wz47329rz2r72r92-secret` on the service +cluster side). By doing it this way, admins only have to think about mutations and rewrites once +(when configuring the primary object in the PublishedResource) and the path will yield 2 ready to +use values (`my-secret` and the computed value). + +References can either return a single scalar (strings or integers that will be auto-converted to a +string) (like in `spec.secretName`) or a list of strings/numbers (like `spec.users.#.name`). A +reference must return the same number of items on both the local and remote object, otherwise the +agent will not be able to map local related names to remote related names correctly. + +A regular expression can be configured to be applied to each found value (i.e. if the reference returns +a list of values, the regular expression is applied to each individual value). + +Here's an example on how to use references to locate the related object. + +{% raw %} +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + resource: + kind: Certificate + apiGroup: cert-manager.io + versions: [v1] + + naming: + # this is where our CA and Issuer live in this example + namespace: kube-system + # need to adjust it to prevent collisions (normally clustername is the namespace) + name: "{{ .ClusterName }}-{{ .Object.metadata.namespace | sha3short }}-{{ .Object.metadata.name | sha3short }}" + + related: + - # unique name for this related resource. The name must be unique within + # one PublishedResource and is the key by which consumers (end users) + # can identify and consume the related resource. Common names are + # "connection-details" or "credentials". + identifier: tls-secret + + # "service" or "kcp" + origin: service + + # the GVR of the related resource + group: "" + version: v1 + resource: secrets + + # configure where in the parent object we can find the child object + object: + # Object can use either reference, labelSelector or template. 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 + # annotation). + path: spec.secretName + + # namespace part is optional; if not configured, + # Sync Agent assumes the same namespace as the owning resource + # namespace: + # reference: + # path: spec.secretName + # regex: + # pattern: '...' + # replacement: '...' +``` +{% endraw %} + +### Templates + +Similar to references, [Go templates](https://pkg.go.dev/text/template) can also be used to determine +the names of related objects on both sides of the sync. In fact, templates can be thought of as more +powerful references since they allow for minimal logic to be embedded in them. Templates also do not +necessarily have to select a value from the object (like a reference does), but can use any kind of +logic to determine the names. + +Like references, templates can also only be used to select a single object per related resource. + +A template gets the following data injected into it: + +```go +type localObjectNamingContext struct { + // Side is set to either one of the possible origin values to indicate for + // which cluster the template is currently being evaluated for. + Side syncagentv1alpha1.RelatedResourceOrigin + // Object is the primary object belonging to the related object. Since related + // object templates are evaluated twice (once for the origin side and once + // for the destination side), object is the primary object on the side the + // template is evaluated for. + Object map[string]any + // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") + // of the kcp workspace that the synchronization is currently processing. This + // value is set for both evaluations, regardless of side. + ClusterName logicalcluster.Name + // ClusterPath is the workspace path (e.g. "root:customer:projectx"). This + // value is set for both evaluations, regardless of side. + ClusterPath logicalcluster.Path +} +``` + +In the simplest form, a template can replace a reference: + +* reference: `.spec.secretName` +* Go template: {% raw %}`{{ .Object.spec.secretName }}`{% endraw %} + +Just like with references, the configured template is evaluated twice, once for each side of the +synchronization. You can use the `Side` variable to allow for fully customized names on each side: + +{% raw %} +```yaml +spec: + ... + related: + - identifier: tls-secret + # ..omitting other fields.. + object: + template: + template: `{{ if eq .Side "kcp" }}name-in-kcp{{ else }}name-on-service-cluster{{ end }}` +``` +{% endraw %} + +See [Templating](templating.md) for more information on how to use templates in PublishedResources. + +### 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 peculiar the underlying operators on the service clusters are. + +Here is an example on how to use label selectors: + +{% raw %} +```yaml +apiVersion: syncagent.kcp.io/v1alpha1 +kind: PublishedResource +metadata: + name: publish-certmanager-certs +spec: + resource: + kind: Certificate + apiGroup: cert-manager.io + versions: [v1] + + naming: + namespace: kube-system + name: "{{ .ClusterName }}-{{ .Object.metadata.namespace | sha3short }}-{{ .Object.metadata.name | sha3short }}" + + related: + - identifier: tls-secrets + + # "service" or "kcp" + origin: service + + group: "" + version: v1 + resource: secrets + + # 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 + # Within matchLabels, keys and values are treated as Go templates. + # In this example, since the Secret originates on the service cluster + # (see "origin" above), we use LocalObject to determine the value + # for the selector. In case the object was heavily mutated during the + # sync, this will give access to the mutated values on the service + # cluster side. + '{{ shasum "test" }}': '{{ .LocalObject.spec.username }}' + + # You also need to provide rules on how objects found by this selector + # should be named on the destination side of the sync. You can choose + # to define a rewrite rule that keeps the original name from the origin + # side, but this may leak undesirable internals to the users. + # 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: "{{ .Value }}-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: + # reference: + # path: metadata.namespace + # regex: + # pattern: '...' + # replacement: '...' +``` +{% endraw %} + +There are two possible usages of Go templates when using label selectors. See [Templating](templating.md) +for more information on how to use templates in PublishedResources in general. + +#### Selector Templates + +Each template rendered as part of a `matchLabels` selector gets the following data injected: + +```go +type relatedObjectLabelContext struct { + // LocalObject is the primary object copy on the local side of the sync + // (i.e. on the service cluster). + LocalObject map[string]any + // RemoteObject is the primary object original, in kcp. + RemoteObject map[string]any + // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") + // of the kcp workspace that the synchronization is currently processing + // (where the remote object exists). + ClusterName logicalcluster.Name + // ClusterPath is the workspace path (e.g. "root:customer:projectx"). + ClusterPath logicalcluster.Path +} +``` + +Note that in contrast to the `template` way of selecting objects, the templates here in the label +selector are only evaluated once, on the origin side of the sync. The names of the destination side +are determined using the rewrite mechanism (which might also be a Go template, see next section). + +#### Rewrite Rules + +Each found related object on the origin side needs to have its own name on the destination side. To +map from the origin to the destination side, regular expressions (see example snippet) or Go +templates can be used. + +If a template is configured, it is evaluated once for every found related object. The template gets +the following data injected into it: + +```go +type relatedObjectLabelRewriteContext struct { + // Value is either the a found namespace name (when a label selector was + // used to select the source namespaces for related objects) or the name of + // a found object (when a label selector was used to find objects). In the + // former case, the template should return the new namespace to use on the + // destination side, in the latter case it should return the new object name + // to use on the destination side. + Value string + // When a rewrite is used to rewrite object names, RelatedObject is the + // original related object (found on the origin side). This enables you to + // ignore the given Value entirely and just select anything from the object + // itself. + // RelatedObject is nil when the rewrite is performed for a namespace. + RelatedObject map[string]any + // LocalObject is the primary object copy on the local side of the sync + // (i.e. on the service cluster). + LocalObject map[string]any + // RemoteObject is the primary object original, in kcp. + RemoteObject map[string]any + // ClusterName is the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") + // of the kcp workspace that the synchronization is currently processing + // (where the remote object exists). + ClusterName logicalcluster.Name + // ClusterPath is the workspace path (e.g. "root:customer:projectx"). + ClusterPath logicalcluster.Path +} +``` + +Regarding `Value`: The agent allows to individually configure rules for finding object _names_ and +object _namespaces_. Often the namespace is not configured because the related objects live in the +same namespace as their owning, primary object. + +When a label selector is configured to find namespaces, the rewrite template will be evaluated once +for each found namespace. In this case the `.Value` is the name of the found namespace. Remember, the +template's job is to map the found namespace to the new namespace on the destination side of the sync. + +Once the namespaces have been determined, the agent will look for matching objects in each namespace +individually. For each namespace it will again follow the configured source, may it be a selector, +template or reference. If again a label selector is used, it will be applied in each namespace and +the configured rewrite rule will be evaluated once per found object. In this case, `.Value` is the +name of found object. diff --git a/docs/requirements.txt b/docs/requirements.txt index d26c143..51bc1f6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,6 @@ mkdocs-macros-plugin==1.3.7 mkdocs-material==9.5.17 mkdocs-material-extensions==1.3.1 mkdocs-static-i18n==1.2.2 + +# https://github.com/mkdocs/mkdocs/issues/4032 +click<=8.2.1 diff --git a/hack/update-codegen-sdk.sh b/hack/update-codegen-sdk.sh index 1dd496e..c79de22 100755 --- a/hack/update-codegen-sdk.sh +++ b/hack/update-codegen-sdk.sh @@ -36,6 +36,10 @@ set -x "object:headerFile=$BOILERPLATE_HEADER" \ paths=./internal/sync/apis/... +"$CONTROLLER_GEN" \ + "object:headerFile=$BOILERPLATE_HEADER" \ + paths=./test/crds/... + cd sdk rm -rf -- applyconfiguration clientset informers listers diff --git a/internal/controller/apiexport/controller.go b/internal/controller/apiexport/controller.go index 1cd144d..eda3a92 100644 --- a/internal/controller/apiexport/controller.go +++ b/internal/controller/apiexport/controller.go @@ -19,13 +19,15 @@ package apiexport import ( "context" "fmt" - "slices" "github.com/kcp-dev/logicalcluster/v3" "go.uber.org/zap" "github.com/kcp-dev/api-syncagent/internal/controllerutil" predicateutil "github.com/kcp-dev/api-syncagent/internal/controllerutil/predicate" + "github.com/kcp-dev/api-syncagent/internal/discovery" + "github.com/kcp-dev/api-syncagent/internal/kcp" + "github.com/kcp-dev/api-syncagent/internal/projection" "github.com/kcp-dev/api-syncagent/internal/resources/reconciling" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" @@ -33,6 +35,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -50,14 +53,15 @@ const ( ) type Reconciler struct { - localClient ctrlruntimeclient.Client - kcpClient ctrlruntimeclient.Client - log *zap.SugaredLogger - recorder record.EventRecorder - lcName logicalcluster.Name - apiExportName string - agentName string - prFilter labels.Selector + localClient ctrlruntimeclient.Client + kcpClient ctrlruntimeclient.Client + discoveryClient *discovery.Client + log *zap.SugaredLogger + recorder record.EventRecorder + lcName logicalcluster.Name + apiExportName string + agentName string + prFilter labels.Selector } // Add creates a new controller and adds it to the given manager. @@ -70,15 +74,21 @@ func Add( agentName string, prFilter labels.Selector, ) error { + discoveryClient, err := discovery.NewClient(mgr.GetConfig()) + if err != nil { + return fmt.Errorf("failed to create discovery client: %w", err) + } + reconciler := &Reconciler{ - localClient: mgr.GetClient(), - kcpClient: kcpCluster.GetClient(), - lcName: lcName, - log: log.Named(ControllerName), - recorder: kcpCluster.GetEventRecorderFor(ControllerName), - apiExportName: apiExportName, - agentName: agentName, - prFilter: prFilter, + localClient: mgr.GetClient(), + kcpClient: kcpCluster.GetClient(), + discoveryClient: discoveryClient, + lcName: lcName, + log: log.Named(ControllerName), + recorder: kcpCluster.GetEventRecorderFor(ControllerName), + apiExportName: apiExportName, + agentName: agentName, + prFilter: prFilter, } hasARS := predicate.NewPredicateFuncs(func(object ctrlruntimeclient.Object) bool { @@ -90,7 +100,7 @@ func Add( return publishedResource.Status.ResourceSchemaName != "" }) - _, err := builder.ControllerManagedBy(mgr). + _, err = builder.ControllerManagedBy(mgr). Named(ControllerName). WithOptions(controller.Options{ // we reconcile a single object in kcp, no need for parallel workers @@ -109,11 +119,24 @@ func Add( func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { r.log.Debug("Processing") - return reconcile.Result{}, r.reconcile(ctx) + + apiExport := &kcpdevv1alpha1.APIExport{} + if err := r.kcpClient.Get(ctx, types.NamespacedName{Name: r.apiExportName}, apiExport); err != nil { + return reconcile.Result{}, ctrlruntimeclient.IgnoreNotFound(err) + } + + return reconcile.Result{}, r.reconcile(ctx, apiExport) } -func (r *Reconciler) reconcile(ctx context.Context) error { - // find all PublishedResources +func (r *Reconciler) reconcile(ctx context.Context, apiExport *kcpdevv1alpha1.APIExport) error { + // find all PublishedResources; we keep those that are not yet converted into ARS, + // just to reduce the amount of re-reconciles when the agent processes a number of PRs in a row + // and would constantly update the APIExport; instead we rely on kcp to handle the eventual + // consistency. + // This allows us to already determine all resources we "own", which helps us in + // bilding the proper permission claims if one of the related resources is using a resource + // type managed via PublishedResource. Otherwise the controller might not see the PR for the + // related resource and temporarily incorrectly assume it needs to add a permission claim. pubResources := &syncagentv1alpha1.PublishedResourceList{} if err := r.localClient.List(ctx, pubResources, &ctrlruntimeclient.ListOptions{ LabelSelector: r.prFilter, @@ -121,75 +144,95 @@ func (r *Reconciler) reconcile(ctx context.Context) error { return fmt.Errorf("failed to list PublishedResources: %w", err) } - // filter out those PRs that have not yet been processed into an ARS - filteredPubResources := slices.DeleteFunc(pubResources.Items, func(pr syncagentv1alpha1.PublishedResource) bool { - return pr.Status.ResourceSchemaName == "" - }) + // Create two lists of schema names: ready schemas are those already processed by the + // apiresourceschema controller, the other list includes all possible schema names. + // For calculating the required permission claims we need _all_ schemas, but we actually + // only want to store ready schemas in the APIExport in order to not confuse other + // downstream components. + readySchemaNames := sets.New[string]() + allSchemaNames := sets.New[string]() + for _, pubResource := range pubResources.Items { + schemaName, err := r.getSchemaName(ctx, &pubResource) + if err != nil { + return fmt.Errorf("failed to determine schema name for PublishedResource %s: %w", pubResource.Name, err) + } - // for each PR, we note down the created ARS and also the GVKs of related resources - arsList := sets.New[string]() - claimedResources := sets.New[kcpdevv1alpha1.GroupResource]() + allSchemaNames.Insert(schemaName) - // PublishedResources use kinds, but the PermissionClaims use resource names (plural), - // so we must translate accordingly - mapper := r.kcpClient.RESTMapper() + if pubResource.Status.ResourceSchemaName != "" { + readySchemaNames.Insert(schemaName) + } + } - for _, pubResource := range filteredPubResources { - arsList.Insert(pubResource.Status.ResourceSchemaName) + // To determine if the GVR of a related resource needs to be listed as a permission claim, + // we first need to figure out all the GVRs our APIExport contains. This is not just the + // list of ARS we just built, but also potentially other ARS's that exist in the APIExport + // and that we would not touch. + mergedSchemaNames := mergeResourceSchemas(apiExport.Spec.LatestResourceSchemas, allSchemaNames) + + // turn the flat list of schema names ("version.resource.group") into a lookup table consisting + // of group/resource only + ourOwnResources := sets.New[schema.GroupResource]() + for _, schemaName := range mergedSchemaNames { + gvr, err := parseSchemaName(schemaName) + if err != nil { + return fmt.Errorf("failed to assemble own resources: %w", err) + } + ourOwnResources.Insert(gvr.GroupResource()) + } + + // Now we can finally assemble the list of required permission claims. + claimedResources := sets.New[permissionClaim]() + + for _, pubResource := range pubResources.Items { // to evaluate the namespace filter, the agent needs to fetch the namespace if filter := pubResource.Spec.Filter; filter != nil && filter.Namespace != nil { - claimedResources.Insert(kcpdevv1alpha1.GroupResource{ + claimedResources.Insert(permissionClaim{ Group: "", Resource: "namespaces", }) } if pubResource.Spec.EnableWorkspacePaths { - claimedResources.Insert(kcpdevv1alpha1.GroupResource{ + claimedResources.Insert(permissionClaim{ Group: "core.kcp.io", Resource: "logicalclusters", }) } + // Add a claim for every foreign (!) related resource, but make sure to + // project the GVR of related resources to their kcp-side equivalent first + // if they originate on the service cluster. for _, rr := range pubResource.Spec.Related { - resource, err := mapper.ResourceFor(schema.GroupVersionResource{ - Resource: rr.Kind, - }) - if err != nil { - return fmt.Errorf("unknown related resource kind %q: %w", rr.Kind, err) - } + kcpGVR := projection.RelatedResourceKcpGVR(&rr) - claimedResources.Insert(kcpdevv1alpha1.GroupResource{ + // We always want to see namespaces, for simplicity. Technically if we knew the scope + // of every related resource, we could determine if a namespace claim is truly necessary. + claimedResources.Insert(permissionClaim{ Group: "", - Resource: resource.Resource, + Resource: "namespaces", }) - } - } - // Related resources (Secrets, ConfigMaps) are namespaced and so the Sync Agent will - // always need to be able to see and manage namespaces. - if claimedResources.Len() > 0 { - claimedResources.Insert(kcpdevv1alpha1.GroupResource{ - Group: "", - Resource: "namespaces", - }) + if !ourOwnResources.Has(kcpGVR.GroupResource()) { + claimedResources.Insert(permissionClaim{ + Group: kcpGVR.Group, + Resource: kcpGVR.Resource, + IdentityHash: rr.IdentityHash, + }) + } + } } // We always want to create events. - claimedResources.Insert(kcpdevv1alpha1.GroupResource{ + claimedResources.Insert(permissionClaim{ Group: "", Resource: "events", }) - if arsList.Len() == 0 { - r.log.Debug("No ready PublishedResources available.") - return nil - } - // reconcile an APIExport in kcp factories := []reconciling.NamedAPIExportReconcilerFactory{ - r.createAPIExportReconciler(arsList, claimedResources, r.agentName, r.apiExportName, r.recorder), + r.createAPIExportReconciler(readySchemaNames, claimedResources, r.agentName, r.apiExportName, r.recorder), } if err := reconciling.ReconcileAPIExports(ctx, factories, "", r.kcpClient); err != nil { @@ -226,3 +269,22 @@ func (r *Reconciler) reconcile(ctx context.Context) error { return nil } + +func (r *Reconciler) getSchemaName(ctx context.Context, pubRes *syncagentv1alpha1.PublishedResource) (string, error) { + if pubRes.Status.ResourceSchemaName != "" { + return pubRes.Status.ResourceSchemaName, nil + } + + // Technically we *could* wait and let the apiresourceschema controller do its + // job and provide the status field above. But this would mean we potentially + // temporarily misidentify related resources, adding unnecessary and invalid + // permission claims in the APIExport. To avoid these it's worth it to basically + // do the same projection logic as the other controller here, just to calculate + // what the name of the schema would/will be. + projectedCRD, err := projection.ProjectPublishedResource(ctx, r.discoveryClient, pubRes) + if err != nil { + return "", fmt.Errorf("failed to apply projection rules: %w", err) + } + + return kcp.GetAPIResourceSchemaName(projectedCRD), nil +} diff --git a/internal/controller/apiexport/reconciler.go b/internal/controller/apiexport/reconciler.go index 16aee9c..abad6ab 100644 --- a/internal/controller/apiexport/reconciler.go +++ b/internal/controller/apiexport/reconciler.go @@ -28,16 +28,34 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/tools/record" ) +// permissionClaim is the same as kcp's PermissionClaim, just trimmed down to +// kind, group and identity and more importantly, without all the other fields +// that makes this struct not comparable (i.e. not suitable for a Set). +type permissionClaim struct { + Group string + Resource string + IdentityHash string +} + +func (c permissionClaim) String() string { + if c.Group == "" { + return c.Resource + } + + return fmt.Sprintf("%s/%s", c.Group, c.Resource) +} + // createAPIExportReconciler creates the reconciler for the APIExport. // WARNING: The APIExport in this is NOT created by the Sync Agent, it's created // by a controller in kcp. Make sure you don't create a reconciling conflict! func (r *Reconciler) createAPIExportReconciler( availableResourceSchemas sets.Set[string], - claimedResourceKinds sets.Set[kcpdevv1alpha1.GroupResource], + claimedResourceKinds sets.Set[permissionClaim], agentName string, apiExportName string, recorder record.EventRecorder, @@ -60,17 +78,21 @@ func (r *Reconciler) createAPIExportReconciler( // only ensure the ones originating from the published resources; // step 1 is to collect all existing claims with the same properties // as ours. - existingClaims := sets.New[kcpdevv1alpha1.GroupResource]() + existingClaims := sets.New[permissionClaim]() for _, claim := range existing.Spec.PermissionClaims { if claim.All && len(claim.ResourceSelector) == 0 { - existingClaims.Insert(claim.GroupResource) + existingClaims.Insert(permissionClaim{ + Group: claim.Group, + Resource: claim.Resource, + IdentityHash: claim.IdentityHash, + }) } } missingClaims := claimedResourceKinds.Difference(existingClaims) claimsToAdd := missingClaims.UnsortedList() - slices.SortStableFunc(claimsToAdd, func(a, b kcpdevv1alpha1.GroupResource) int { + slices.SortStableFunc(claimsToAdd, func(a, b permissionClaim) int { if a.Group != b.Group { return strings.Compare(a.Group, b.Group) } @@ -81,15 +103,19 @@ func (r *Reconciler) createAPIExportReconciler( // add our missing claims for _, claimed := range claimsToAdd { existing.Spec.PermissionClaims = append(existing.Spec.PermissionClaims, kcpdevv1alpha1.PermissionClaim{ - GroupResource: claimed, - All: true, + GroupResource: kcpdevv1alpha1.GroupResource{ + Group: claimed.Group, + Resource: claimed.Resource, + }, + All: true, + IdentityHash: claimed.IdentityHash, }) } if missingClaims.Len() > 0 { claims := make([]string, 0, len(claimsToAdd)) for _, claimed := range claimsToAdd { - claims = append(claims, groupResourceToString(claimed)) + claims = append(claims, claimed.String()) } recorder.Eventf(existing, corev1.EventTypeNormal, "AddingPermissionClaims", "Added new permission claim(s) for all %s.", strings.Join(claims, ", ")) } @@ -112,14 +138,6 @@ func (r *Reconciler) createAPIExportReconciler( } } -func groupResourceToString(gr kcpdevv1alpha1.GroupResource) string { - if gr.Group == "" { - return gr.Resource - } - - return fmt.Sprintf("%s/%s", gr.Group, gr.Resource) -} - func mergeResourceSchemas(existing []string, configured sets.Set[string]) []string { var result []string @@ -162,8 +180,23 @@ func createSchemaEvents(obj runtime.Object, oldSchemas, newSchemas []string, rec } func parseResourceGroup(schema string) string { + gvr, _ := parseSchemaName(schema) + return gvr.GroupResource().String() +} + +// parseSchemaName parses an APIResourceSchema name and returns it in form of +// a GVR. Note: the version in the result will not be a Kubernetes version, but +// the version of the ARS! +func parseSchemaName(name string) (schema.GroupVersionResource, error) { // .. - parts := strings.SplitN(schema, ".", 2) + parts := strings.SplitN(name, ".", 3) + if len(parts) != 3 { + return schema.GroupVersionResource{}, fmt.Errorf("invalid schema name %q, must consist of version.resource.group", name) + } - return parts[1] + return schema.GroupVersionResource{ + Group: parts[2], + Version: parts[0], + Resource: parts[1], + }, nil } diff --git a/internal/controller/apiresourceschema/controller.go b/internal/controller/apiresourceschema/controller.go index f1bd0e0..bacb4ce 100644 --- a/internal/controller/apiresourceschema/controller.go +++ b/internal/controller/apiresourceschema/controller.go @@ -144,22 +144,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( } func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, pubResource *syncagentv1alpha1.PublishedResource) (*reconcile.Result, error) { - // find the resource that the PublishedResource is referring to - localGK := projection.PublishedResourceSourceGK(pubResource) - + // get the projected CRD (i.e. strip unwanted versions, rename values etc.) client, err := discovery.NewClient(r.restConfig) if err != nil { return nil, fmt.Errorf("failed to create discovery client: %w", err) } - // fetch the original, full CRD from the cluster - crd, err := client.RetrieveCRD(ctx, localGK) - if err != nil { - return nil, fmt.Errorf("failed to discover resource defined in PublishedResource: %w", err) - } - - // project the CRD (i.e. strip unwanted versions, rename values etc.) - projectedCRD, err := projection.ProjectCRD(crd, pubResource) + projectedCRD, err := projection.ProjectPublishedResource(ctx, client, pubResource) if err != nil { return nil, fmt.Errorf("failed to apply projection rules: %w", err) } diff --git a/internal/controller/syncmanager/controller.go b/internal/controller/syncmanager/controller.go index 4458f3a..cebe24f 100644 --- a/internal/controller/syncmanager/controller.go +++ b/internal/controller/syncmanager/controller.go @@ -408,10 +408,16 @@ func getPublishedResourceKey(pr *syncagentv1alpha1.PublishedResource) string { return fmt.Sprintf("%s-%s", pr.UID, pr.ResourceVersion) } +func isSyncEnabled(pr *syncagentv1alpha1.PublishedResource) bool { + return pr.Spec.Synchronization == nil || pr.Spec.Synchronization.Enabled +} + func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.SugaredLogger, publishedResources []syncagentv1alpha1.PublishedResource) error { requiredWorkers := sets.New[string]() for _, pr := range publishedResources { - requiredWorkers.Insert(getPublishedResourceKey(&pr)) + if isSyncEnabled(&pr) { + requiredWorkers.Insert(getPublishedResourceKey(&pr)) + } } // stop controllers that are no longer needed @@ -430,8 +436,11 @@ func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.Sugared } // start missing controllers - for idx := range publishedResources { - pubRes := publishedResources[idx] + for _, pubRes := range publishedResources { + if !isSyncEnabled(&pubRes) { + continue + } + key := getPublishedResourceKey(&pubRes) // controller already exists diff --git a/internal/projection/projection.go b/internal/projection/projection.go index 5b57405..16b62bd 100644 --- a/internal/projection/projection.go +++ b/internal/projection/projection.go @@ -17,11 +17,13 @@ limitations under the License. package projection import ( + "context" "errors" "fmt" "slices" "strings" + "github.com/kcp-dev/api-syncagent/internal/discovery" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -113,6 +115,89 @@ func ProjectCRD(crd *apiextensionsv1.CustomResourceDefinition, pubRes *syncagent return result, nil } +func ProjectPublishedResource(ctx context.Context, client *discovery.Client, pubRes *syncagentv1alpha1.PublishedResource) (*apiextensionsv1.CustomResourceDefinition, error) { + // find the resource that the PublishedResource is referring to + localGK := PublishedResourceSourceGK(pubRes) + + // fetch the original, full CRD from the cluster + crd, err := client.RetrieveCRD(ctx, localGK) + if err != nil { + return nil, fmt.Errorf("failed to discover resource defined in PublishedResource: %w", err) + } + + // project the CRD (i.e. strip unwanted versions, rename values etc.) + projectedCRD, err := ProjectCRD(crd, pubRes) + if err != nil { + return nil, fmt.Errorf("failed to apply projection rules: %w", err) + } + + return projectedCRD, nil +} + +func RelatedResourceGVR(rr *syncagentv1alpha1.RelatedResourceSpec) schema.GroupVersionResource { + resultGVR := schema.GroupVersionResource{ + Group: rr.Group, + Version: rr.Version, + Resource: rr.Resource, + } + + // handle legacy kinds + //nolint:staticcheck + switch rr.Kind { + case "ConfigMap": + resultGVR.Group = "" + resultGVR.Version = "v1" + resultGVR.Resource = "configmaps" + case "Secret": + resultGVR.Group = "" + resultGVR.Version = "v1" + resultGVR.Resource = "secrets" + } + + return resultGVR +} + +// RelatedResourceProjectedGVR returns the effective GVR on the destination side, +// after the projection rules for the related resource have been applied. +func RelatedResourceProjectedGVR(rr *syncagentv1alpha1.RelatedResourceSpec) schema.GroupVersionResource { + resultGVR := RelatedResourceGVR(rr) + + projection := rr.Projection + if projection == nil { + return resultGVR + } + + if projection.Group != "" { + resultGVR.Group = projection.Group + } + + if projection.Version != "" { + resultGVR.Version = projection.Version + } + + if projection.Resource != "" { + resultGVR.Resource = projection.Resource + } + + return resultGVR +} + +func RelatedResourceKcpGVR(rr *syncagentv1alpha1.RelatedResourceSpec) schema.GroupVersionResource { + if rr.Origin == syncagentv1alpha1.RelatedResourceOriginKcp { + return RelatedResourceGVR(rr) + } + + return RelatedResourceProjectedGVR(rr) +} + +func RelatedResourceServiceGVK(rr *syncagentv1alpha1.RelatedResourceSpec) schema.GroupVersionResource { + if rr.Origin == syncagentv1alpha1.RelatedResourceOriginService { + return RelatedResourceGVR(rr) + } + + return RelatedResourceProjectedGVR(rr) +} + func stripUnwantedVersions(crd *apiextensionsv1.CustomResourceDefinition, pubRes *syncagentv1alpha1.PublishedResource) (*apiextensionsv1.CustomResourceDefinition, error) { src := pubRes.Spec.Resource diff --git a/internal/sync/syncer_related.go b/internal/sync/syncer_related.go index c177cb8..c65f0a1 100644 --- a/internal/sync/syncer_related.go +++ b/internal/sync/syncer_related.go @@ -28,6 +28,7 @@ import ( "github.com/tidwall/gjson" "go.uber.org/zap" + "github.com/kcp-dev/api-syncagent/internal/projection" "github.com/kcp-dev/api-syncagent/internal/sync/templating" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" @@ -97,11 +98,18 @@ func (s *ResourceSyncer) processRelatedResource(ctx context.Context, log *zap.Su return strings.Compare(aKey, bKey) }) - // Synchronize objects the same way the parent object was synchronized. + // Synchronize related objects the same way the parent object was synchronized. + projectedGVR := projection.RelatedResourceProjectedGVR(&relRes) + + projectedGVK, err := dest.client.RESTMapper().KindFor(projectedGVR) + if err != nil { + return false, fmt.Errorf("failed to lookup %v: %w", projectedGVR, err) + } + 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) + destObject.SetAPIVersion(projectedGVK.GroupVersion().String()) + destObject.SetKind(projectedGVK.Kind) if err = dest.client.Get(ctx, resolved.destination, destObject); err != nil { destObject = nil @@ -128,17 +136,24 @@ func (s *ResourceSyncer) processRelatedResource(ctx context.Context, log *zap.Su // how to create a new destination object destCreator: func(source *unstructured.Unstructured) (*unstructured.Unstructured, error) { dest := source.DeepCopy() + dest.SetAPIVersion(projectedGVK.GroupVersion().String()) + dest.SetKind(projectedGVK.Kind) dest.SetName(resolved.destination.Name) dest.SetNamespace(resolved.destination.Namespace) return dest, nil }, - // ConfigMaps and Secrets have no subresources + // Originally related resources were only ConfigMaps and Secrets, which do not have subresources; + // nowadays we support arbitrary APIs for related resources, but for simplicity do not [yet?] + // support syncing the subresources back. For this we would first need to figure out which + // subresources even exist. 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", + // Theoretically we would only want to sync the status back if the related resource + // originates in kcp, because it would be weird if the service provider relied on status + // information provided to them by the consumer. + // However since we do not know anything about subresources, we currently cannot enable this + // feature at all. + syncStatusBack: false, // 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", @@ -171,8 +186,8 @@ func (s *ResourceSyncer) processRelatedResource(ctx context.Context, log *zap.Su value, err := json.Marshal(relatedObjectAnnotation{ Namespace: resolved.destination.Namespace, Name: resolved.destination.Name, - APIVersion: "v1", // we only support ConfigMaps and Secrets - Kind: relRes.Kind, + APIVersion: resolved.original.GetAPIVersion(), + Kind: resolved.original.GetKind(), }) if err != nil { return false, fmt.Errorf("failed to encode related object annotation: %w", err) @@ -355,9 +370,16 @@ func resolveRelatedResourceObjectsInNamespaces(ctx context.Context, relatedOrigi } for originName, destName := range nameMap { + originGVR := projection.RelatedResourceGVR(&relRes) + + originGVK, err := relatedOrigin.client.RESTMapper().KindFor(originGVR) + if err != nil { + return nil, fmt.Errorf("failed to lookup %v: %w", originGVR, err) + } + originObj := &unstructured.Unstructured{} - originObj.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 - originObj.SetKind(relRes.Kind) + originObj.SetAPIVersion(originGVK.GroupVersion().String()) + originObj.SetKind(originGVK.Kind) err = relatedOrigin.client.Get(ctx, types.NamespacedName{Name: originName, Namespace: originNamespace}, originObj) if err != nil { @@ -407,9 +429,16 @@ func resolveRelatedResourceObjectsInNamespace(ctx context.Context, relatedOrigin return mapSlices(originNames, destNames), nil case spec.Selector != nil: + originGVR := projection.RelatedResourceGVR(&relRes) + + originGVK, err := relatedOrigin.client.RESTMapper().KindFor(originGVR) + if err != nil { + return nil, fmt.Errorf("failed to lookup %v: %w", originGVR, err) + } + originObjects := &unstructured.UnstructuredList{} - originObjects.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1 - originObjects.SetKind(relRes.Kind) + originObjects.SetAPIVersion(originGVK.GroupVersion().String()) + originObjects.SetKind(originGVK.Kind) labelSelector, err := templateLabelSelector(relatedOrigin, relatedDest, relRes.Origin, &spec.Selector.LabelSelector) if err != nil { diff --git a/internal/sync/syncer_test.go b/internal/sync/syncer_test.go index ff91756..0b21532 100644 --- a/internal/sync/syncer_test.go +++ b/internal/sync/syncer_test.go @@ -31,19 +31,36 @@ import ( "github.com/kcp-dev/api-syncagent/internal/test/diff" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" yamlutil "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/tools/record" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" fakectrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" ) +func newFakeClientBuilder() *fakectrlruntimeclient.ClientBuilder { + scheme := runtime.NewScheme() + + utilruntime.Must(metav1.AddMetaToScheme(scheme)) + utilruntime.Must(corev1.AddToScheme(scheme)) + + mapper := meta.NewDefaultRESTMapper(nil) + for gvk := range scheme.AllKnownTypes() { + mapper.Add(gvk, meta.RESTScopeNamespace) + } + + return fakectrlruntimeclient.NewClientBuilder().WithScheme(scheme).WithRESTMapper(mapper) +} + func buildFakeClient(objs ...*unstructured.Unstructured) ctrlruntimeclient.Client { - builder := fakectrlruntimeclient.NewClientBuilder() + builder := newFakeClientBuilder() for i, obj := range objs { if obj != nil { builder.WithObjects(objs[i]) @@ -54,7 +71,7 @@ func buildFakeClient(objs ...*unstructured.Unstructured) ctrlruntimeclient.Clien } func buildFakeClientWithStatus(objs ...*unstructured.Unstructured) ctrlruntimeclient.Client { - builder := fakectrlruntimeclient.NewClientBuilder() + builder := newFakeClientBuilder() for i, obj := range objs { if obj != nil { builder.WithObjects(objs[i]) diff --git a/internal/sync/templating/templating_test.go b/internal/sync/templating/templating_test.go index 654eddd..e432d79 100644 --- a/internal/sync/templating/templating_test.go +++ b/internal/sync/templating/templating_test.go @@ -21,7 +21,7 @@ import ( "github.com/kcp-dev/logicalcluster/v3" - "github.com/kcp-dev/api-syncagent/test/crds" + crds "github.com/kcp-dev/api-syncagent/test/crds/example/v1" "github.com/kcp-dev/api-syncagent/test/utils" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/sdk/apis/syncagent/v1alpha1/published_resource.go b/sdk/apis/syncagent/v1alpha1/published_resource.go index e35a977..a750510 100644 --- a/sdk/apis/syncagent/v1alpha1/published_resource.go +++ b/sdk/apis/syncagent/v1alpha1/published_resource.go @@ -96,7 +96,13 @@ type PublishedResourceSpec struct { // directions during the synchronization. Mutation *ResourceMutationSpec `json:"mutation,omitempty"` + // Related configures additional resources that semantically belong to the synced + // resource, like a Secret containing generated credentials. Related objects are + // synced along the main resource. Related []RelatedResourceSpec `json:"related,omitempty"` + + // Synchronization allows to configure how the syncagent processes this resource. + Synchronization *SynchronizationSpec `json:"synchronization,omitempty"` } // ResourceNaming describes how the names for local objects should be formed. @@ -189,6 +195,15 @@ const ( RelatedResourceOriginKcp RelatedResourceOrigin = "kcp" ) +// RelatedResourceSpec describes a single related resource, which might point to +// any number of actual Kubernetes objects. +// +// (in the following rule, group is optional becaue core/v1 is represented by group="") +// +kubebuilder:validation:XValidation:rule="has(self.kind) != (has(self.version) || has(self.resource))",message="must specify either kind (deprecated) or group, version, resource" +// +kubebuilder:validation:XValidation:rule="has(self.resource) == has(self.version)",message="resource and version must be configured together or not at all" +// +kubebuilder:validation:XValidation:rule="!has(self.group) || (has(self.resource) && has(self.version))",message="configuring a group also requires a version and resource" +// group is included here because when an identityHash is used, core/v1 cannot possible be targetted +// +kubebuilder:validation:XValidation:rule="!has(self.identityHash) || (has(self.group) && has(self.version) && has(self.resource))",message="identity hashes can only be used with GVRs" type RelatedResourceSpec struct { // Identifier is a unique name for this related resource. The name must be unique within one // PublishedResource and is the key by which consumers (end users) can identify and consume the @@ -199,8 +214,34 @@ type RelatedResourceSpec struct { // +kubebuilder:validation:Enum=service;kcp Origin RelatedResourceOrigin `json:"origin"` - // ConfigMap or Secret - Kind string `json:"kind"` + // Group is the API group of the related resource. This should be left blank for resources + // in the core API group. + Group string `json:"group,omitempty"` + + // Version is the API version of the related resource. This can be left blank to automatically + // use the preferred version. + Version string `json:"version,omitempty"` + + // Resource is the name of the related resource (for example "secrets"). + Resource string `json:"resource,omitempty"` + + // Kind is the object kind of the related resource (for example "Secret"). + // + // Deprecated: Use "Resource" instead. This field is limited to "ConfigMap" and "Secret" and will + // be removed in the future. Kind and Resource cannot be specified at the same time. + // + // +kubebuilder:validation:Enum=ConfigMap;Secret + Kind string `json:"kind,omitempty"` + + // IdentityHash is the identity hash of a kcp APIExport, in case the given Kind is + // provided by an APIExport and not Kube-native. + IdentityHash string `json:"identityHash,omitempty"` + + // Projection is used to change the GVK of a related resource on the opposite side of + // its origin. + // All fields in the projection are optional. If a field is set, it will overwrite + // that field in the GVK. + Projection *RelatedResourceProjection `json:"projection,omitempty"` // 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. @@ -211,6 +252,18 @@ type RelatedResourceSpec struct { Mutation *ResourceMutationSpec `json:"mutation,omitempty"` } +// RelatedResourceProjection describes how the source GVK of a related resource (i.e. +// the GVK on the related resource's origin side) should be modified when an object +// is copied from the origin to the destination. +type RelatedResourceProjection struct { + // The API group, for example "myservice.example.com". Leave empty to not modify the API group. + Group string `json:"group,omitempty"` + // The API version, for example "v1beta1". Leave empty to not modify the version. + Version string `json:"version,omitempty"` + // The resource name, for example "databases". Leave empty to not modify the resource. + Resource string `json:"resource,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 { @@ -358,6 +411,19 @@ type ResourceFilter struct { Resource *metav1.LabelSelector `json:"resource,omitempty"` } +// SynchronizationSpec allows to configure how the syncagent processes a +// PublishedResource. +type SynchronizationSpec struct { + // Enabled can be used to toggle the synchronization as a whole. When set to + // false, the syncagent will only copy the CRD and include it in the APIExport, + // but not will attempt to synchronize objects of this resource from the kcp + // workspaces to the provider. + // Synchronization must be disabled for resources that are used as related + // resources for other PublishedResources. Otherwise the syncagent would + // potentially loop and never finish processing an object. + Enabled bool `json:"enabled"` +} + // PublishedResourceStatus stores status information about a published resource. type PublishedResourceStatus struct { ResourceSchemaName string `json:"resourceSchemaName,omitempty"` diff --git a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go index 8da6b2f..e0ad61b 100644 --- a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go +++ b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go @@ -115,6 +115,11 @@ func (in *PublishedResourceSpec) DeepCopyInto(out *PublishedResourceSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Synchronization != nil { + in, out := &in.Synchronization, &out.Synchronization + *out = new(SynchronizationSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublishedResourceSpec. @@ -245,6 +250,21 @@ func (in *RelatedResourceObjectSpec) DeepCopy() *RelatedResourceObjectSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelatedResourceProjection) DeepCopyInto(out *RelatedResourceProjection) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelatedResourceProjection. +func (in *RelatedResourceProjection) DeepCopy() *RelatedResourceProjection { + if in == nil { + return nil + } + out := new(RelatedResourceProjection) + 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 @@ -273,6 +293,11 @@ func (in *RelatedResourceSelectorRewrite) DeepCopy() *RelatedResourceSelectorRew // 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 + if in.Projection != nil { + in, out := &in.Projection, &out.Projection + *out = new(RelatedResourceProjection) + **out = **in + } in.Object.DeepCopyInto(&out.Object) if in.Mutation != nil { in, out := &in.Mutation, &out.Mutation @@ -487,6 +512,21 @@ func (in *SourceResourceDescriptor) DeepCopy() *SourceResourceDescriptor { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SynchronizationSpec) DeepCopyInto(out *SynchronizationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SynchronizationSpec. +func (in *SynchronizationSpec) DeepCopy() *SynchronizationSpec { + if in == nil { + return nil + } + out := new(SynchronizationSpec) + 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 diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go b/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go index dede444..f4c427d 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/publishedresourcespec.go @@ -28,6 +28,7 @@ type PublishedResourceSpecApplyConfiguration struct { Projection *ResourceProjectionApplyConfiguration `json:"projection,omitempty"` Mutation *ResourceMutationSpecApplyConfiguration `json:"mutation,omitempty"` Related []RelatedResourceSpecApplyConfiguration `json:"related,omitempty"` + Synchronization *SynchronizationSpecApplyConfiguration `json:"synchronization,omitempty"` } // PublishedResourceSpecApplyConfiguration constructs a declarative configuration of the PublishedResourceSpec type for use with @@ -96,3 +97,11 @@ func (b *PublishedResourceSpecApplyConfiguration) WithRelated(values ...*Related } return b } + +// WithSynchronization sets the Synchronization 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 Synchronization field is set to the value of the last call. +func (b *PublishedResourceSpecApplyConfiguration) WithSynchronization(value *SynchronizationSpecApplyConfiguration) *PublishedResourceSpecApplyConfiguration { + b.Synchronization = value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceprojection.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceprojection.go new file mode 100644 index 0000000..afa17f1 --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourceprojection.go @@ -0,0 +1,57 @@ +/* +Copyright 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-v0.33. DO NOT EDIT. + +package v1alpha1 + +// RelatedResourceProjectionApplyConfiguration represents a declarative configuration of the RelatedResourceProjection type for use +// with apply. +type RelatedResourceProjectionApplyConfiguration struct { + Group *string `json:"group,omitempty"` + Version *string `json:"version,omitempty"` + Resource *string `json:"resource,omitempty"` +} + +// RelatedResourceProjectionApplyConfiguration constructs a declarative configuration of the RelatedResourceProjection type for use with +// apply. +func RelatedResourceProjection() *RelatedResourceProjectionApplyConfiguration { + return &RelatedResourceProjectionApplyConfiguration{} +} + +// WithGroup sets the Group 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 Group field is set to the value of the last call. +func (b *RelatedResourceProjectionApplyConfiguration) WithGroup(value string) *RelatedResourceProjectionApplyConfiguration { + b.Group = &value + return b +} + +// WithVersion sets the Version 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 Version field is set to the value of the last call. +func (b *RelatedResourceProjectionApplyConfiguration) WithVersion(value string) *RelatedResourceProjectionApplyConfiguration { + b.Version = &value + return b +} + +// WithResource sets the Resource 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 Resource field is set to the value of the last call. +func (b *RelatedResourceProjectionApplyConfiguration) WithResource(value string) *RelatedResourceProjectionApplyConfiguration { + b.Resource = &value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go index 3614266..5496c40 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/relatedresourcespec.go @@ -25,11 +25,16 @@ import ( // RelatedResourceSpecApplyConfiguration represents a declarative configuration of the RelatedResourceSpec type for use // with apply. type RelatedResourceSpecApplyConfiguration struct { - Identifier *string `json:"identifier,omitempty"` - Origin *syncagentv1alpha1.RelatedResourceOrigin `json:"origin,omitempty"` - Kind *string `json:"kind,omitempty"` - Object *RelatedResourceObjectApplyConfiguration `json:"object,omitempty"` - Mutation *ResourceMutationSpecApplyConfiguration `json:"mutation,omitempty"` + Identifier *string `json:"identifier,omitempty"` + Origin *syncagentv1alpha1.RelatedResourceOrigin `json:"origin,omitempty"` + Group *string `json:"group,omitempty"` + Version *string `json:"version,omitempty"` + Resource *string `json:"resource,omitempty"` + Kind *string `json:"kind,omitempty"` + IdentityHash *string `json:"identityHash,omitempty"` + Projection *RelatedResourceProjectionApplyConfiguration `json:"projection,omitempty"` + Object *RelatedResourceObjectApplyConfiguration `json:"object,omitempty"` + Mutation *ResourceMutationSpecApplyConfiguration `json:"mutation,omitempty"` } // RelatedResourceSpecApplyConfiguration constructs a declarative configuration of the RelatedResourceSpec type for use with @@ -54,6 +59,30 @@ func (b *RelatedResourceSpecApplyConfiguration) WithOrigin(value syncagentv1alph return b } +// WithGroup sets the Group 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 Group field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithGroup(value string) *RelatedResourceSpecApplyConfiguration { + b.Group = &value + return b +} + +// WithVersion sets the Version 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 Version field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithVersion(value string) *RelatedResourceSpecApplyConfiguration { + b.Version = &value + return b +} + +// WithResource sets the Resource 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 Resource field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithResource(value string) *RelatedResourceSpecApplyConfiguration { + b.Resource = &value + return b +} + // WithKind sets the Kind 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 Kind field is set to the value of the last call. @@ -62,6 +91,22 @@ func (b *RelatedResourceSpecApplyConfiguration) WithKind(value string) *RelatedR return b } +// WithIdentityHash sets the IdentityHash 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 IdentityHash field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithIdentityHash(value string) *RelatedResourceSpecApplyConfiguration { + b.IdentityHash = &value + return b +} + +// WithProjection sets the Projection field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Projection field is set to the value of the last call. +func (b *RelatedResourceSpecApplyConfiguration) WithProjection(value *RelatedResourceProjectionApplyConfiguration) *RelatedResourceSpecApplyConfiguration { + b.Projection = value + return b +} + // 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 Object field is set to the value of the last call. diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/synchronizationspec.go b/sdk/applyconfiguration/syncagent/v1alpha1/synchronizationspec.go new file mode 100644 index 0000000..84dddfd --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/synchronizationspec.go @@ -0,0 +1,39 @@ +/* +Copyright 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-v0.33. DO NOT EDIT. + +package v1alpha1 + +// SynchronizationSpecApplyConfiguration represents a declarative configuration of the SynchronizationSpec type for use +// with apply. +type SynchronizationSpecApplyConfiguration struct { + Enabled *bool `json:"enabled,omitempty"` +} + +// SynchronizationSpecApplyConfiguration constructs a declarative configuration of the SynchronizationSpec type for use with +// apply. +func SynchronizationSpec() *SynchronizationSpecApplyConfiguration { + return &SynchronizationSpecApplyConfiguration{} +} + +// WithEnabled sets the Enabled 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 Enabled field is set to the value of the last call. +func (b *SynchronizationSpecApplyConfiguration) WithEnabled(value bool) *SynchronizationSpecApplyConfiguration { + b.Enabled = &value + return b +} diff --git a/sdk/applyconfiguration/utils.go b/sdk/applyconfiguration/utils.go index be8ef81..0310667 100644 --- a/sdk/applyconfiguration/utils.go +++ b/sdk/applyconfiguration/utils.go @@ -49,6 +49,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &syncagentv1alpha1.RelatedResourceObjectSelectorApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceObjectSpec"): return &syncagentv1alpha1.RelatedResourceObjectSpecApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceProjection"): + return &syncagentv1alpha1.RelatedResourceProjectionApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceSelectorRewrite"): return &syncagentv1alpha1.RelatedResourceSelectorRewriteApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceSpec"): @@ -71,6 +73,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &syncagentv1alpha1.ResourceTemplateMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("SourceResourceDescriptor"): return &syncagentv1alpha1.SourceResourceDescriptorApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("SynchronizationSpec"): + return &syncagentv1alpha1.SynchronizationSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("TemplateExpression"): return &syncagentv1alpha1.TemplateExpressionApplyConfiguration{} diff --git a/test/crds/backup.go b/test/crds/example/v1/backup.go similarity index 95% rename from test/crds/backup.go rename to test/crds/example/v1/backup.go index a05bea6..0b1dfc6 100644 --- a/test/crds/backup.go +++ b/test/crds/example/v1/backup.go @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package crds +package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// +kubebuilder:object:root=true + type Backup struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/test/crds/crontab.go b/test/crds/example/v1/crontab.go similarity index 95% rename from test/crds/crontab.go rename to test/crds/example/v1/crontab.go index 8b34a44..7c83076 100644 --- a/test/crds/crontab.go +++ b/test/crds/example/v1/crontab.go @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package crds +package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// +kubebuilder:object:root=true + type Crontab struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/test/crds/example/v1/doc.go b/test/crds/example/v1/doc.go new file mode 100644 index 0000000..fe72720 --- /dev/null +++ b/test/crds/example/v1/doc.go @@ -0,0 +1,22 @@ +/* +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. +*/ + +// GroupName is wrong here, but irrelevant since we only create deepcopy funcs. + +// +groupName=dummy.example.com +// +versionName=v1 +// +kubebuilder:object:generate=true +package v1 diff --git a/test/crds/example/v1/zz_generated.deepcopy.go b/test/crds/example/v1/zz_generated.deepcopy.go new file mode 100644 index 0000000..8ad8838 --- /dev/null +++ b/test/crds/example/v1/zz_generated.deepcopy.go @@ -0,0 +1,127 @@ +//go:build !ignore_autogenerated + +/* +Copyright 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 controller-gen. DO NOT EDIT. + +package v1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Backup) DeepCopyInto(out *Backup) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Backup. +func (in *Backup) DeepCopy() *Backup { + if in == nil { + return nil + } + out := new(Backup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Backup) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackupItem) DeepCopyInto(out *BackupItem) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupItem. +func (in *BackupItem) DeepCopy() *BackupItem { + if in == nil { + return nil + } + out := new(BackupItem) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackupSpec) DeepCopyInto(out *BackupSpec) { + *out = *in + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BackupItem, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupSpec. +func (in *BackupSpec) DeepCopy() *BackupSpec { + if in == nil { + return nil + } + out := new(BackupSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Crontab) DeepCopyInto(out *Crontab) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Crontab. +func (in *Crontab) DeepCopy() *Crontab { + if in == nil { + return nil + } + out := new(Crontab) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Crontab) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrontabSpec) DeepCopyInto(out *CrontabSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrontabSpec. +func (in *CrontabSpec) DeepCopy() *CrontabSpec { + if in == nil { + return nil + } + out := new(CrontabSpec) + in.DeepCopyInto(out) + return out +} diff --git a/test/e2e/apiexport/apiexport_test.go b/test/e2e/apiexport/apiexport_test.go index 5631c83..06371a7 100644 --- a/test/e2e/apiexport/apiexport_test.go +++ b/test/e2e/apiexport/apiexport_test.go @@ -94,7 +94,7 @@ func TestPermissionsClaims(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -134,7 +134,9 @@ func TestPermissionsClaims(t *testing.T) { { Identifier: "super-secret", Origin: "kcp", - Kind: "Secret", + Resource: "secrets", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -151,7 +153,9 @@ func TestPermissionsClaims(t *testing.T) { { Identifier: "other-super-secret", Origin: "service", - Kind: "Secret", + Resource: "secrets", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -171,7 +175,9 @@ func TestPermissionsClaims(t *testing.T) { { Identifier: "config", Origin: "kcp", - Kind: "ConfigMap", + Resource: "configmaps", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -305,7 +311,9 @@ func TestExistingPermissionsClaimsAreKept(t *testing.T) { { Identifier: "super-secret", Origin: "kcp", - Kind: "Secret", + Resource: "secrets", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -328,7 +336,7 @@ func TestExistingPermissionsClaimsAreKept(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated expectedClaims := []kcpapisv1alpha1.PermissionClaim{ @@ -430,7 +438,9 @@ func TestSchemasAreMerged(t *testing.T) { { Identifier: "super-secret", Origin: "kcp", - Kind: "Secret", + Resource: "secrets", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -453,7 +463,7 @@ func TestSchemasAreMerged(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -520,7 +530,9 @@ func TestSchemaIsKeptWhenDeletingPublishedResource(t *testing.T) { { Identifier: "super-secret", Origin: "kcp", - Kind: "Secret", + Resource: "secrets", + Group: "", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -543,7 +555,7 @@ func TestSchemaIsKeptWhenDeletingPublishedResource(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be contain the new ARS t.Logf("Waiting for APIExport to be updated…") @@ -657,7 +669,8 @@ func TestNewSchemasAreCreatedAsNeeded(t *testing.T) { { Identifier: "super-secret", Origin: "kcp", - Kind: "Secret", + Resource: "secrets", + Version: "v1", Object: syncagentv1alpha1.RelatedResourceObject{ RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ Reference: &syncagentv1alpha1.RelatedResourceObjectReference{ @@ -680,7 +693,7 @@ func TestNewSchemasAreCreatedAsNeeded(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") diff --git a/test/e2e/apiresourceschema/apiresourceschema_test.go b/test/e2e/apiresourceschema/apiresourceschema_test.go index c4d2108..136c88d 100644 --- a/test/e2e/apiresourceschema/apiresourceschema_test.go +++ b/test/e2e/apiresourceschema/apiresourceschema_test.go @@ -76,7 +76,7 @@ func TestARSAreCreated(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -147,7 +147,7 @@ func TestARSAreNotUpdated(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // check ARS t.Logf("Waiting for APIResourceSchema to be created…") @@ -254,7 +254,7 @@ func TestARSOnlyContainsSelectedCRDVersion(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -336,7 +336,7 @@ func TestMultiVersionCRD(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -433,7 +433,7 @@ func TestProjection(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") @@ -532,7 +532,7 @@ func TestNonCRDResource(t *testing.T) { } // let the agent do its thing - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait for the APIExport to be updated t.Logf("Waiting for APIExport to be updated…") diff --git a/test/e2e/sdk/cel_validations_test.go b/test/e2e/sdk/cel_validations_test.go new file mode 100644 index 0000000..3fa3c36 --- /dev/null +++ b/test/e2e/sdk/cel_validations_test.go @@ -0,0 +1,208 @@ +//go:build e2e + +/* +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 sdk + +import ( + "regexp" + "strings" + "testing" + + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + "github.com/kcp-dev/api-syncagent/test/utils" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestValidateRelatedResourceSpec(t *testing.T) { + // start a service cluster + _, envtestClient, _ := utils.RunEnvtest(t, nil) + + testcases := []struct { + name string + spec syncagentv1alpha1.RelatedResourceSpec + valid bool + }{ + { + name: "legacy kind ConfigMap", + valid: true, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "ConfigMap", + }, + }, + { + name: "invalid legacy kind", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "Service", + }, + }, + { + name: "cannot combine kind with group", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "ConfigMap", + Group: "core", + }, + }, + { + name: "cannot combine kind with resource", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "ConfigMap", + Resource: "configmaps", + }, + }, + { + name: "cannot combine kind with version", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "ConfigMap", + Version: "v1", + }, + }, + { + name: "cannot combine kind with identityHash", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Kind: "ConfigMap", + IdentityHash: "abc123", + }, + }, + { + name: "vanilla core/v1 resource", + valid: true, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "configmaps", + Version: "v1", + }, + }, + { + name: "resource requires version", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "configmaps", + }, + }, + { + name: "vanilla random GVR", + valid: true, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Resource: "things", + Version: "v1", + }, + }, + { + name: "group requires resource", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Version: "v1", + }, + }, + { + name: "group requires version", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Resource: "things", + }, + }, + { + name: "vanilla foreign resource", + valid: true, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Resource: "things", + Version: "v1", + IdentityHash: "abc123", + }, + }, + { + name: "identity hash requires group", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "things", + Version: "v1", + IdentityHash: "abc123", + }, + }, + { + name: "identity hash requires resource", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Version: "v1", + IdentityHash: "abc123", + }, + }, + { + name: "identity hash requires version", + valid: false, + spec: syncagentv1alpha1.RelatedResourceSpec{ + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Group: "example.com", + Resource: "things", + IdentityHash: "abc123", + }, + }, + } + + alphaNum := regexp.MustCompile(`[^a-z0-9]`) + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + crdName := strings.ToLower(tt.name) + crdName = alphaNum.ReplaceAllLiteralString(crdName, "-") + + pubRes := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-" + crdName, + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Related: []syncagentv1alpha1.RelatedResourceSpec{tt.spec}, + }, + } + + err := envtestClient.Create(t.Context(), pubRes) + + if tt.valid { + if err != nil { + t.Fatalf("Spec is valid, but server returned: %v", err) + } + } else if err == nil { + t.Fatal("Spec is invalid, but server accepted it.") + } + }) + } +} diff --git a/test/e2e/sync/apiexportendpointslice_test.go b/test/e2e/sync/apiexportendpointslice_test.go index b589ae1..b8930dd 100644 --- a/test/e2e/sync/apiexportendpointslice_test.go +++ b/test/e2e/sync/apiexportendpointslice_test.go @@ -110,7 +110,7 @@ func TestAPIExportEndpointSliceSameCluster(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API; - utils.RunEndpointSliceAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, endpointSlice.Name) + utils.RunEndpointSliceAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, endpointSlice.Name, "") // wait until the API is available teamClusterPath := logicalcluster.NewPath("root").Join(orgWorkspace).Join("team-1") @@ -253,7 +253,7 @@ func TestAPIExportEndpointSliceDifferentCluster(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunEndpointSliceAgent(ctx, t, "bob", endpointKubeconfig, envtestKubeconfig, endpointSlice.Name) + utils.RunEndpointSliceAgent(ctx, t, "bob", endpointKubeconfig, envtestKubeconfig, endpointSlice.Name, "") // wait until the API is available diff --git a/test/e2e/sync/primary_test.go b/test/e2e/sync/primary_test.go index 9778d12..52e501a 100644 --- a/test/e2e/sync/primary_test.go +++ b/test/e2e/sync/primary_test.go @@ -88,7 +88,7 @@ func TestSyncSimpleObject(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -181,7 +181,7 @@ func TestSyncSimpleObjectOldNaming(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -268,7 +268,7 @@ func TestSyncWithDefaultNamingRules(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -371,7 +371,7 @@ func TestLocalChangesAreKept(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -568,7 +568,7 @@ func TestResourceFilter(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -685,7 +685,7 @@ func TestSyncingOverlyLongNames(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -787,7 +787,7 @@ func TestSyncWithWorkspacePath(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -912,7 +912,7 @@ func TestSyncMultiResources(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) diff --git a/test/e2e/sync/related_test.go b/test/e2e/sync/related_test.go index 764371f..89f0655 100644 --- a/test/e2e/sync/related_test.go +++ b/test/e2e/sync/related_test.go @@ -30,15 +30,21 @@ import ( "github.com/go-logr/logr" "github.com/kcp-dev/logicalcluster/v3" + "github.com/kcp-dev/api-syncagent/internal/projection" "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" + crds "github.com/kcp-dev/api-syncagent/test/crds/example/v1" "github.com/kcp-dev/api-syncagent/test/utils" + kcpapisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/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/runtime" "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" @@ -579,7 +585,7 @@ func TestSyncRelatedObjects(t *testing.T) { } // start the agent in the background to update the APIExport with the CronTabs API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available kcpClusterClient := utils.GetKcpAdminClusterClient(t) @@ -631,7 +637,7 @@ func TestSyncRelatedObjects(t *testing.T) { t.Fatalf("Failed to wait for Secret to be synced: %v", err) } - if err := compareSecrets(copySecret, testcase.expectedSyncedRelatedObject); err != nil { + if err := compareSecrets(t, copySecret, testcase.expectedSyncedRelatedObject); err != nil { t.Fatalf("Synced secret does not match expected Secret:\n%v", err) } }) @@ -942,7 +948,7 @@ func TestSyncRelatedMultiObjects(t *testing.T) { } // start the agent in the background to update the APIExport with the Backups API - utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName) + utils.RunAgent(ctx, t, "bob", orgKubconfig, envtestKubeconfig, apiExportName, "") // wait until the API is available utils.WaitForBoundAPI(t, ctx, teamClient, schema.GroupVersionResource{ @@ -970,7 +976,7 @@ func TestSyncRelatedMultiObjects(t *testing.T) { return fmt.Errorf("failed to get copy of Secret %v: %w", ctrlruntimeclient.ObjectKeyFromObject(&expected), err) } - if err := compareSecrets(*copySecret, expected); err != nil { + if err := compareSecrets(t, *copySecret, expected); err != nil { return fmt.Errorf("synced secret does not match expected Secret:\n%w", err) } @@ -1000,22 +1006,596 @@ func TestSyncRelatedMultiObjects(t *testing.T) { } } -func compareSecrets(actual, expected corev1.Secret) error { +func TestSyncNonStandardRelatedResources(t *testing.T) { + const apiExportName = "kcp.example.com" + + ctrlruntime.SetLogger(logr.Discard()) + + testcases := []struct { + // the name of this testcase + name string + // the org workspace everything should happen in + workspace string + // 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 ctrlruntimeclient.Object + // expectation: this is how the copy of the related object should look + // like after the sync has completed + expectedSyncedRelatedObject ctrlruntimeclient.Object + }{ + { + name: "turn a related ConfigMap into a Secret", + workspace: "sync-related-configmap-to-secret", + mainResource: crds.Crontab{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kcp.example.com/v1", + Kind: "CronTab", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{}, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "credentials", + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "configmaps", + Version: "v1", + Projection: &syncagentv1alpha1.RelatedResourceProjection{ + Resource: "secrets", + }, + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Template: &syncagentv1alpha1.TemplateExpression{ + // same fixed value on both sides + Template: "my-credentials", + }, + }, + }, + }, + sourceRelatedObject: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "synced-default", + }, + Data: map[string]string{ + "password": "aHVudGVyMg==", + }, + }, + expectedSyncedRelatedObject: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-credentials", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte("hunter2"), + }, + Type: corev1.SecretTypeOpaque, + }, + }, + + ////////////////////////////////////////////////////////////////////////////////////////////// + + { + name: "use a resource from the same APIExport as a related resource", + workspace: "sync-related-same-apiexport", + mainResource: crds.Crontab{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kcp.example.com/v1", + Kind: "CronTab", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{ + CronSpec: "* * *", + Image: "ubuntu:latest", + }, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "the-backup", + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "backups", + Group: "eksempel.no", + Version: "v1", + Projection: &syncagentv1alpha1.RelatedResourceProjection{ + Group: "kcp.example.com", + }, + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Template: &syncagentv1alpha1.TemplateExpression{ + // same fixed value on both sides + Template: "my-backup", + }, + }, + }, + }, + sourceRelatedObject: &crds.Backup{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "eksempel.no/v1", + Kind: "Backup", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "synced-default", + }, + Spec: crds.BackupSpec{}, + }, + expectedSyncedRelatedObject: &crds.Backup{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kcp.example.com/v1", + Kind: "Backup", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "default", + }, + Spec: crds.BackupSpec{}, + }, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + ctx := t.Context() + + // setup a test environment in kcp + orgKubconfig := utils.CreateOrganization(t, ctx, testcase.workspace, apiExportName) + + // start a service cluster + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ + "test/crds/crontab.yaml", + "test/crds/backup.yaml", + }) + + // publish Crontabs and Backups + t.Logf("Publishing CRDs…") + prCrontabs := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-crontabs", + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "example.com", + Version: "v1", + Kind: "CronTab", + }, + // These rules make finding the local object easier, but should not be used in production. + Naming: &syncagentv1alpha1.ResourceNaming{ + Name: "{{ .Object.metadata.name }}", + Namespace: "synced-{{ .Object.metadata.namespace }}", + }, + Projection: &syncagentv1alpha1.ResourceProjection{ + Group: "kcp.example.com", + }, + Related: []syncagentv1alpha1.RelatedResourceSpec{testcase.relatedConfig}, + }, + } + + if err := envtestClient.Create(ctx, prCrontabs); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // backups are published to be used as related resources only + prBackups := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-backups", + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "eksempel.no", + Version: "v1", + Kind: "Backup", + }, + Projection: &syncagentv1alpha1.ResourceProjection{ + Group: "kcp.example.com", + }, + Synchronization: &syncagentv1alpha1.SynchronizationSpec{ + Enabled: false, + }, + }, + } + + if err := envtestClient.Create(ctx, prBackups); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // t.Logf("waiting...") + // time.Sleep(20 * time.Second) + + // 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 + kcpClusterClient := utils.GetKcpAdminClusterClient(t) + + teamClusterPath := logicalcluster.NewPath("root").Join(testcase.workspace).Join("team-1") + teamClient := kcpClusterClient.Cluster(teamClusterPath) + + utils.WaitForBoundAPI(t, ctx, teamClient, schema.GroupVersionResource{ + Group: apiExportName, + Version: "v1", + Resource: "crontabs", + }) + + // create a Crontab object in a team workspace + t.Log("Creating CronTab in kcp…") + + crontab := utils.ToUnstructured(t, &testcase.mainResource) + if err := teamClient.Create(ctx, crontab); err != nil { + t.Fatalf("Failed to create CronTab in kcp: %v", err) + } + + // fake operator: create a related object as a result of processing the main object + t.Logf("Creating original related object on the %s side…", testcase.relatedConfig.Origin) + + originClient := envtestClient + destClient := teamClient + + if testcase.relatedConfig.Origin == syncagentv1alpha1.RelatedResourceOriginKcp { + originClient, destClient = destClient, originClient + } + + ensureNamespace(t, ctx, originClient, testcase.sourceRelatedObject.GetNamespace()) + + relatedObject := utils.ToUnstructured(t, &testcase.sourceRelatedObject) + if err := originClient.Create(ctx, relatedObject); err != nil { + t.Fatalf("Failed to create related object: %v", err) + } + + // wait for the agent to do its magic + t.Log("Wait for related object to be synced…") + projectedGVR := projection.RelatedResourceProjectedGVR(&testcase.relatedConfig) + projectedGVK, err := destClient.RESTMapper().KindFor(projectedGVR) + if err != nil { + t.Fatalf("Failed to resolve projected GVR %v: %v", projectedGVR, err) + } + + copiedRelatedObject := &unstructured.Unstructured{} + copiedRelatedObject.SetAPIVersion(projectedGVK.GroupVersion().String()) + copiedRelatedObject.SetKind(projectedGVK.Kind) + + err = wait.PollUntilContextTimeout(ctx, 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, copiedRelatedObject) == nil, nil + }) + if err != nil { + t.Fatalf("Failed to wait for related object to be synced: %v", err) + } + + if err := compareUnstructured(copiedRelatedObject, toUnstructured(t, testcase.expectedSyncedRelatedObject)); err != nil { + t.Fatalf("Synced copy does not match expected object:\n%v", err) + } + }) + } +} + +func TestSyncNonStandardRelatedResourcesMultipleAPIExports(t *testing.T) { + const ( + initechAPIExportName = "initech.example.com" + initroidAPIExportName = "initroid.example.com" + ) + + ctrlruntime.SetLogger(logr.Discard()) + + testcases := []struct { + // the name of this testcase + name string + // the org workspacePrefix prefix everything should happen in; this test will create + // two organizations with one APIExport each + workspacePrefix string + // 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 ctrlruntimeclient.Object + // expectation: this is how the copy of the related object should look + // like after the sync has completed + expectedSyncedRelatedObject ctrlruntimeclient.Object + }{ + { + name: "use a resource from another APIExport as a related resource", + workspacePrefix: "sync-related-foreign-apiexport", + mainResource: crds.Crontab{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "initech.example.com/v1", + Kind: "CronTab", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-crontab", + Namespace: "default", + }, + Spec: crds.CrontabSpec{ + CronSpec: "* * *", + Image: "ubuntu:latest", + }, + }, + relatedConfig: syncagentv1alpha1.RelatedResourceSpec{ + Identifier: "the-backup", + Origin: syncagentv1alpha1.RelatedResourceOriginService, + Resource: "backups", + Group: "eksempel.no", + Version: "v1", + Projection: &syncagentv1alpha1.RelatedResourceProjection{ + Group: initroidAPIExportName, + }, + Object: syncagentv1alpha1.RelatedResourceObject{ + RelatedResourceObjectSpec: syncagentv1alpha1.RelatedResourceObjectSpec{ + Template: &syncagentv1alpha1.TemplateExpression{ + // same fixed value on both sides + Template: "my-backup", + }, + }, + }, + }, + sourceRelatedObject: &crds.Backup{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "eksempel.no/v1", + Kind: "Backup", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "synced-default", + }, + Spec: crds.BackupSpec{}, + }, + expectedSyncedRelatedObject: &crds.Backup{ + TypeMeta: metav1.TypeMeta{ + APIVersion: initroidAPIExportName + "/v1", + Kind: "Backup", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-backup", + Namespace: "default", + }, + Spec: crds.BackupSpec{}, + }, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + ctx := t.Context() + + // setup a test environment in kcp + initechOrgWorkspace := testcase.workspacePrefix + "-initech" + initroidOrgWorkspace := testcase.workspacePrefix + "-initroid" + + initechOrgKubconfig := utils.CreateOrganization(t, ctx, initechOrgWorkspace, initechAPIExportName) + initroidOrgKubconfig := utils.CreateOrganization(t, ctx, initroidOrgWorkspace, initroidAPIExportName) + + // In this testcase, since we are referring to another APIExport, we need to get its + // identity hash first and update the relatedConfig. + kcpClusterClient := utils.GetKcpAdminClusterClient(t) + initroidClient := kcpClusterClient.Cluster(logicalcluster.NewPath("root").Join(initroidOrgWorkspace)) + + initroidAPIExport := &kcpapisv1alpha1.APIExport{} + err := initroidClient.Get(ctx, types.NamespacedName{Name: initroidAPIExportName}, initroidAPIExport) + if err != nil { + t.Fatalf("Failed to find Initroid APIExport: %v", err) + } + + // In this testcase, initech wants to consume initroid APIs, so we must bind to their APIExport, too. + initechOrg := logicalcluster.NewPath("root").Join(initechOrgWorkspace) + + for _, team := range []string{"team-1", "team-2"} { + utils.BindToAPIExport(t, ctx, kcpClusterClient.Cluster(initechOrg.Join(team)), initroidAPIExport) + } + + // start a service cluster + envtestKubeconfig, envtestClient, _ := utils.RunEnvtest(t, []string{ + "test/crds/crontab.yaml", + "test/crds/backup.yaml", + }) + + t.Logf("Detected identity hash %q", initroidAPIExport.Status.IdentityHash) + testcase.relatedConfig.IdentityHash = initroidAPIExport.Status.IdentityHash + + // publish Crontabs and Backups + t.Logf("Publishing CRDs…") + prCrontabs := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-crontabs", + Labels: map[string]string{ + "agent": "initech", + }, + }, + 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: "{{ .Object.metadata.name }}", + Namespace: "synced-{{ .Object.metadata.namespace }}", + }, + Projection: &syncagentv1alpha1.ResourceProjection{ + Group: initechAPIExportName, + }, + Related: []syncagentv1alpha1.RelatedResourceSpec{testcase.relatedConfig}, + }, + } + + if err := envtestClient.Create(ctx, prCrontabs); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // backups are published to be used as related resources only + prBackups := &syncagentv1alpha1.PublishedResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "publish-backups", + Labels: map[string]string{ + "agent": "initroid", + }, + }, + Spec: syncagentv1alpha1.PublishedResourceSpec{ + Resource: syncagentv1alpha1.SourceResourceDescriptor{ + APIGroup: "eksempel.no", + Version: "v1", + Kind: "Backup", + }, + Projection: &syncagentv1alpha1.ResourceProjection{ + Group: initroidAPIExportName, + }, + Synchronization: &syncagentv1alpha1.SynchronizationSpec{ + Enabled: false, + }, + }, + } + + if err := envtestClient.Create(ctx, prBackups); err != nil { + t.Fatalf("Failed to create PublishedResource: %v", err) + } + + // start the agents in the background to update the APIExports + utils.RunAgent(ctx, t, "initech", initechOrgKubconfig, envtestKubeconfig, initechAPIExportName, "agent=initech") + utils.RunAgent(ctx, t, "initroid", initroidOrgKubconfig, envtestKubeconfig, initroidAPIExportName, "agent=initroid") + + // wait until the APIs are available + for orgWs, gvr := range map[string]schema.GroupVersionResource{ + initechOrgWorkspace: {Group: initechAPIExportName, Version: "v1", Resource: "crontabs"}, + initroidOrgWorkspace: {Group: initroidAPIExportName, Version: "v1", Resource: "backups"}, + } { + teamClusterPath := logicalcluster.NewPath("root").Join(orgWs).Join("team-1") + teamClient := kcpClusterClient.Cluster(teamClusterPath) + + utils.WaitForBoundAPI(t, ctx, teamClient, gvr) + } + + // Since we are claiming resources from other APIExports, the default accepted claims + // the test utils provisioned are not enough anymore and we need to accept the new, actual + // list of claims. This has to happen after the agent had time to configure the expected + // claims on the APIExport. + initechClient := kcpClusterClient.Cluster(logicalcluster.NewPath("root").Join(initechOrgWorkspace)) + + initechAPIExport := &kcpapisv1alpha1.APIExport{} + err = initechClient.Get(ctx, types.NamespacedName{Name: initechAPIExportName}, initechAPIExport) + if err != nil { + t.Fatalf("Failed to find Initech APIExport: %v", err) + } + + for _, team := range []string{"team-1", "team-2"} { + utils.AcceptAllPermissionClaims(t, ctx, kcpClusterClient.Cluster(initechOrg.Join(team)), initechAPIExport) + } + + // create a Crontab object in a team workspace + t.Log("Creating CronTab in kcp…") + + initechTeamClusterPath := logicalcluster.NewPath("root").Join(initechOrgWorkspace).Join("team-1") + initechTeamClient := kcpClusterClient.Cluster(initechTeamClusterPath) + + crontab := utils.ToUnstructured(t, &testcase.mainResource) + if err := initechTeamClient.Create(ctx, crontab); err != nil { + t.Fatalf("Failed to create CronTab in kcp: %v", err) + } + + // fake operator: create a related object as a result of processing the main object + t.Logf("Creating original related object on the %s side…", testcase.relatedConfig.Origin) + + originClient := envtestClient + destClient := initechTeamClient + + if testcase.relatedConfig.Origin == syncagentv1alpha1.RelatedResourceOriginKcp { + originClient, destClient = destClient, originClient + } + + ensureNamespace(t, ctx, originClient, testcase.sourceRelatedObject.GetNamespace()) + + relatedObject := utils.ToUnstructured(t, &testcase.sourceRelatedObject) + if err := originClient.Create(ctx, relatedObject); err != nil { + t.Fatalf("Failed to create related object: %v", err) + } + + t.Log("sove...") + time.Sleep(30 * time.Second) + + // wait for the agents to do their magic + t.Log("Wait for related object to be synced…") + projectedGVR := projection.RelatedResourceProjectedGVR(&testcase.relatedConfig) + projectedGVK, err := destClient.RESTMapper().KindFor(projectedGVR) + if err != nil { + t.Fatalf("Failed to resolve projected GVR %v: %v", projectedGVR, err) + } + + copiedRelatedObject := &unstructured.Unstructured{} + copiedRelatedObject.SetAPIVersion(projectedGVK.GroupVersion().String()) + copiedRelatedObject.SetKind(projectedGVK.Kind) + + err = wait.PollUntilContextTimeout(ctx, 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, copiedRelatedObject) == nil, nil + }) + if err != nil { + t.Fatalf("Failed to wait for related object to be synced: %v", err) + } + + if err := compareUnstructured(copiedRelatedObject, toUnstructured(t, testcase.expectedSyncedRelatedObject)); err != nil { + t.Fatalf("Synced copy does not match expected object:\n%v", err) + } + }) + } +} + +func toUnstructured(t *testing.T, obj ctrlruntimeclient.Object) *unstructured.Unstructured { + data, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatalf("Failed to encode object as unstructured: %v", err) + } + + unstructuredObj := &unstructured.Unstructured{Object: data} + unstructuredObj.SetGroupVersionKind(obj.GetObjectKind().GroupVersionKind()) + + return unstructuredObj +} + +func compareSecrets(t *testing.T, actual, expected corev1.Secret) error { + return compareUnstructured(toUnstructured(t, &actual), toUnstructured(t, &expected)) +} + +func compareUnstructured(actual, expected *unstructured.Unstructured) error { // ensure the secret in kcp does not have any sync-related metadata - maps.DeleteFunc(actual.Labels, func(k, v string) bool { + labels := actual.GetLabels() + maps.DeleteFunc(labels, func(k, v string) bool { return strings.HasPrefix(k, "claimed.internal.apis.kcp.io/") }) + if len(labels) == 0 { + labels = nil + } + actual.SetLabels(labels) - delete(actual.Annotations, "kcp.io/cluster") - if len(actual.Annotations) == 0 { - actual.Annotations = nil + annotations := actual.GetAnnotations() + delete(annotations, "kcp.io/cluster") + if len(annotations) == 0 { + annotations = nil } + actual.SetAnnotations(annotations) + + // doing a.SetCreation(b.GetCreation()) doesn't work due to nil values in metav1.Time... + actual.SetCreationTimestamp(metav1.Time{}) + expected.SetCreationTimestamp(metav1.Time{}) - actual.CreationTimestamp = expected.CreationTimestamp - actual.Generation = expected.Generation - actual.ResourceVersion = expected.ResourceVersion - actual.ManagedFields = expected.ManagedFields - actual.UID = expected.UID + actual.SetGeneration(expected.GetGeneration()) + actual.SetResourceVersion(expected.GetResourceVersion()) + actual.SetManagedFields(expected.GetManagedFields()) + actual.SetUID(expected.GetUID()) if changes := diff.ObjectDiff(expected, actual); changes != "" { return errors.New(changes) diff --git a/test/utils/fixtures.go b/test/utils/fixtures.go index 45a8711..e9abfe7 100644 --- a/test/utils/fixtures.go +++ b/test/utils/fixtures.go @@ -266,7 +266,10 @@ func BindToAPIExport(t *testing.T, ctx context.Context, client ctrlruntimeclient }, State: kcpapisv1alpha1.ClaimAccepted, }, - // for related resources, the agent can also sync ConfigMaps and Secrets + // for related resources, the agent can also sync ConfigMaps and Secrets; + // for all testcases that use foreign resources (i.e. those not part of + // the core Kubernetes group or the same APIExport), the tests will adjust + // these permission claims later. { PermissionClaim: kcpapisv1alpha1.PermissionClaim{ GroupResource: kcpapisv1alpha1.GroupResource{ @@ -321,6 +324,39 @@ func BindToAPIExport(t *testing.T, ctx context.Context, client ctrlruntimeclient return apiBinding } +func AcceptAllPermissionClaims(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, apiExport *kcpapisv1alpha1.APIExport) { + allBindings := &kcpapisv1alpha1.APIBindingList{} + if err := client.List(ctx, allBindings); err != nil { + t.Fatalf("Failed to list APIBindings: %v", err) + } + + var apiBinding *kcpapisv1alpha1.APIBinding + for _, binding := range allBindings.Items { + // for simplicity, we only match against the name + if exp := binding.Spec.Reference.Export; exp != nil && exp.Name == apiExport.Name { + apiBinding = &binding + break + } + } + + if apiBinding == nil { + t.Fatalf("No APIBinding found that binds %s.", apiExport.Name) + } + + accepted := []kcpapisv1alpha1.AcceptablePermissionClaim{} + for _, claim := range apiExport.Spec.PermissionClaims { + accepted = append(accepted, kcpapisv1alpha1.AcceptablePermissionClaim{ + PermissionClaim: claim, + State: kcpapisv1alpha1.ClaimAccepted, + }) + } + + apiBinding.Spec.PermissionClaims = accepted + if err := client.Update(ctx, apiBinding); err != nil { + t.Fatalf("Failed to update APIBinding: %v", err) + } +} + func ApplyCRD(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, filename string) { t.Helper() diff --git a/test/utils/process.go b/test/utils/process.go index eaea61c..ca3690a 100644 --- a/test/utils/process.go +++ b/test/utils/process.go @@ -75,8 +75,9 @@ func RunEndpointSliceAgent( kcpKubeconfig string, localKubeconfig string, apiExportEndpointSlice string, + labelSelector string, ) context.CancelFunc { - return runAgent(ctx, t, name, kcpKubeconfig, localKubeconfig, "--apiexportendpointslice-ref", apiExportEndpointSlice) + return runAgent(ctx, t, name, kcpKubeconfig, localKubeconfig, "--apiexportendpointslice-ref", apiExportEndpointSlice, labelSelector) } func RunAgent( @@ -86,8 +87,9 @@ func RunAgent( kcpKubeconfig string, localKubeconfig string, apiExport string, + labelSelector string, ) context.CancelFunc { - return runAgent(ctx, t, name, kcpKubeconfig, localKubeconfig, "--apiexport-ref", apiExport) + return runAgent(ctx, t, name, kcpKubeconfig, localKubeconfig, "--apiexport-ref", apiExport, labelSelector) } func runAgent( @@ -98,6 +100,7 @@ func runAgent( localKubeconfig string, refFlag string, refValue string, + labelSelector string, ) context.CancelFunc { t.Helper() @@ -116,6 +119,10 @@ func runAgent( "--metrics-address", "0", } + if labelSelector != "" { + args = append(args, "--published-resource-selector", labelSelector) + } + logFile := filepath.Join(ArtifactsDirectory(t), uniqueLogfile(t, "")) log, err := os.Create(logFile) if err != nil { diff --git a/test/utils/wait.go b/test/utils/wait.go index 161bcad..b8fe446 100644 --- a/test/utils/wait.go +++ b/test/utils/wait.go @@ -48,7 +48,7 @@ func WaitForObject(t *testing.T, ctx context.Context, client ctrlruntimeclient.C func WaitForBoundAPI(t *testing.T, ctx context.Context, client ctrlruntimeclient.Client, gvr schema.GroupVersionResource) { t.Helper() - t.Log("Waiting for API to be bound in kcp…") + t.Logf("Waiting for API %s/%s to be bound in kcp…", gvr.Group, gvr.Resource) err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, 1*time.Minute, false, func(ctx context.Context) (bool, error) { apiBindings := &kcpapisv1alpha1.APIBindingList{} err := client.List(ctx, apiBindings)