README
¶
function-resources-merger
An arbitrary resources merging function for Crossplane.
⚙️ Installing this function | 🔎 How-to-use | 🚀 Get started with an Example
This is a crossplane function that merges Kubernetes resources data and produces a single resulting resource containing the merged result. It supports any kind of resource.
Requirements
crossplane≥ v2.0.0
Installing this function
[!NOTE] This function needs no RBAC. It never contacts the API server: Crossplane resolves the sources and applies the result under its own
ServiceAccount. Crossplane's defaultClusterRolecoversConfigMaps,Secrets andapiextensions.crossplane.ioresources. To target an arbitrary CRD, grant Crossplane aClusterRolelabelledrbac.crossplane.io/aggregate-to-crossplane: "true". ADeploymentRuntimeConfigstill applies, for replicas, resource limits and node placement.
- Install using
kubectl:
cat <<EOF | kubectl apply -f -
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-resources-merger
spec:
package: ghcr.io/pcanilho/crossplane-function-resources-merger:v0.4.1
EOF
- Installing using
helm:
# Chart.yaml
...
dependencies:
- name: crossplane
version: <your-crossplane-version>
repository: https://charts.crossplane.io/master/
---
# values.yaml
crossplane:
function:
packages:
- ghcr.io/pcanilho/crossplane-function-resources-merger:v0.4.1
How-to-use
Function Merge specification
debug [expand]
Optional
If set to true, the function will output debug information.
target [expand]
Mandatory
Specifies the target resource that will be created/managed by this function.
| Field | Description |
|---|---|
namespace |
The namespace where the target resource will be created/managed. Omit for cluster-scoped kinds. Ignored under a namespaced composite, which pins the target to its own namespace. |
namespaceFromCompositeFieldPath |
(Optional) A field path on the composite resource to resolve the namespace from. |
name |
The target resource's metadata.name. The crossplane.io/composition-resource-name annotation is derived from the resource's identity, not this value; see the worked example below. |
nameFromCompositeFieldPath |
(Optional) A field path on the composite resource to resolve the name from. |
apiVersion |
The API version of the target resource. |
kind |
The kind of the target resource. |
toFieldPath |
The field where the merged data is written. (defaults to data) |
metadata |
(Optional) labels/annotations to set on the target resource. |
stringifyScalars |
(Optional) Coerces a top-level non-string scalar (number or boolean) to a string for a ConfigMap or Secret target instead of failing. Off by default. One-way: a coerced value never returns as a number or boolean. Every coerced key is named on the Merged condition. |
readiness |
(Optional) True or False. Reported on the composed resource. (defaults to True) |
context |
(Optional) Additionally writes the merged result into the Composition context. See below. |
[!TIP] ➤ context (
object)
Field Description keyThe Composition context key the merged result is written to. Required when contextis set.This is additive, not an alternative output: the composed resource above is still created either way. It lets a later pipeline step, for example
function-patch-and-transformorfunction-go-templating, read the merged result straight fromcontext[key]without a second lookup of the composed resource. Writing the pipeline context is not unique to this function:function-extra-resourcesandfunction-environment-configsboth do it, the latter's own description being "retrieve and merge EnvironmentConfigs into the Context". What differs here is the merge configuration, since neither of those exposes amergeStrategyor the other options above.The published value is the merged result itself, regardless of the target's
kind: it is not stringified for aConfigMaptarget and not base64-encoded for aSecrettarget. Those encodings exist only because of the composed resource's own Kubernetes API shape, and context is an independent destination.
[!NOTE] Changing
name, or the valuenameFromCompositeFieldPathresolves to, drops the old target: Crossplane deletes the old object and creates a new one, it does not move data. ChangingapiVersionbetween served versions of the same kind does not recreate it, since the target is identified by group and kind only.
[!NOTE]
readinessis deliberately a two-value enum,TrueorFalse, with no unset orUnspecifiedoption. Crossplane collapses an unspecified readiness to false rather than "unknown", which leaves the composite atReady=False, Reason=Creatingwith an immediate requeue and an event per reconcile, andfunction-auto-readycannot rescue it. Setreadiness: "False"only when another mechanism is responsible for flipping it true.
[!NOTE] Under a namespaced composite resource, Crossplane pins the target to the composite's own namespace, so
target.namespaceis ignored: a warning is emitted and the ignored value is noted on theMergedcondition. ASecrettarget whosetarget.namespacedisagrees is a fatal error rather than a warning, since relocating secret material is not a safe thing to do quietly. A cluster-scoped target such asEnvironmentConfigcannot be composed by a namespaced composite at all.
[!IMPORTANT] Under a namespaced composite, a source may only be read from the composite's own namespace, or from a cluster-scoped kind. Reading another namespace requires both
allowCrossNamespaceon the source and that namespace inallowedSourceNamespaces. This is defence in depth against mistakes: Crossplane itself does not confine reads, and they run under its own cluster-wideServiceAccount. It is not a tenant isolation boundary, since Compositions are cluster-scoped and only platform operators can write them.
sources [expand]
Mandatory
A list of resources that will be used to merge into the target resource.
| Field | Description |
|---|---|
name |
A unique key identifying this source. |
ref |
The apiVersion, kind and name of the resource, plus its namespace. Omit namespace for cluster-scoped kinds such as EnvironmentConfig. |
ref.nameFromCompositeFieldPath |
(Optional) Resolves ref.name from a field path on the composite resource, for example spec.tenant. Mutually exclusive with ref.name; one of the two is required. The resolved value must be a DNS subdomain. Cannot be combined with allowCrossNamespace; see the note below. |
resolution |
(Optional) Required or Optional. (defaults to Required) |
fromFieldPath |
(Optional) The field to read data from. (defaults to data) If absent on an otherwise existing resource, the source contributes nothing rather than failing. |
allowCrossNamespace |
(Optional) Permits this source to be read from a namespace other than the composite's. The namespace must also appear in allowedSourceNamespaces. Ignored for a cluster-scoped composite. Cannot be combined with ref.nameFromCompositeFieldPath. |
toFieldPath |
(Optional) Places this source's contribution under a dotted subtree of the merged result, for example teams.payments, instead of at the root. Runs before the fold, so mergeStrategy still applies to the nested shape. (defaults to the root) |
parse |
(Optional) Scopes and shapes embedded-blob parsing for this source, overriding the Input-level parseEmbedded. See below. |
[!TIP] ➤ parse (
object)A source with no
parseblock follows the Input-levelparseEmbedded, which is retained unchanged. Where both are set, the source's ownparsewins.
Field Description format(Optional) Auto,YAMLorJSON. Forces re-encoding in that serialization, overriding the origin detected while parsing. Applies only to aConfigMaporSecrettarget; any other target keeps the parsed value as a native map, andformathas no effect. (defaults toAuto)keys(Optional) The data keys to parse. Empty means every key, which is what the Input-level parseEmbeddeddoes.
keysfixes the prose hazard below: name only the keys that hold embedded config, and prose containing:is left as a string.formatfixes the JSON hazard on a source whose values hold JSON blobs, scoping withkeysif only some do: set it toJSONand those values re-encode as JSON, not YAML.
allowedSourceNamespaces [expand]
Optional
The namespaces a source may be read from besides the composite's own, when that source also sets
allowCrossNamespace. Both are required: the flag says which sources may cross a namespace, the
list says which namespaces are trusted. Ignored for a cluster-scoped composite.
[!NOTE] Sources are read from the API server, not from the Composition's desired state, so a resource composed by an earlier step of the same pipeline is not visible to this function as a source.
[!TIP] Both
targetandsourceshave full support for both standard kubernetes resources and custom-resources.
Per-tenant sources
One Composition, one overlay ConfigMap per tenant. ref.nameFromCompositeFieldPath reads the
name from the composite, so the Composition does not have to be duplicated per tenant:
sources:
- name: base
ref:
namespace: platform
name: base-config
apiVersion: v1
kind: ConfigMap
- name: overlay
ref:
namespace: platform
nameFromCompositeFieldPath: spec.tenant
apiVersion: v1
kind: ConfigMap
[!NOTE] This example assumes a cluster-scoped composite. Both sources pin
namespace: platformstatically; under a namespaced composite outsideplatformthat is a cross-namespace read, andallowCrossNamespacecannot rescue it, since it is rejected alongsideref.nameFromCompositeFieldPath. A namespaced composite needs its overlay sources pinned to its own namespace instead.
A composite with spec.tenant: acme merges platform/base-config then platform/acme. The
Merged condition records every resolution, so the resource actually read is visible on the
composite: 2 of 2 sources merged; resolved names: overlay -> acme.
[!IMPORTANT] Under a namespaced composite, the name is confined to the composite's own namespace: Crossplane pins every read there regardless of what the field path resolves to. Under a cluster-scoped composite, as in this example, there is no composite namespace to confine to; the bound is the static
ref.namespacethe Composition author pinned instead. Whoever can write the composite chooses the name, not the namespace, and Crossplane fetches sources under its own cluster-wide ServiceAccount, which no per-name RBAC restricts. Two things bound that:
- There is deliberately no
namespaceFromCompositeFieldPathon a source. The namespace stays a static string chosen by the Composition author.ref.nameFromCompositeFieldPathandallowCrossNamespacecannot be combined, and the function rejects a source that sets both.allowedSourceNamespacespins a namespace but not a name; it was written for a statically named source, where the admin pinned both. Together they would let a composite name any resource of that kind inside a permitted foreign namespace, which is exactly the read the pair exists to confine.What remains: under a namespaced composite, a source with a resolved name can be steered at any resource of its kind in the composite's own namespace, including one the composite's owner could not read directly if their RBAC excludes it by
resourceNames. Treat a resolved name as reachable by whoever writes the composite, and keep such sources to kinds whose whole namespace that principal may already read.Resolution happens where the requirement is declared, so a name that is missing, empty, or not a DNS subdomain omits that source's selector entirely and fails the composition. A source that is never requested is a resource that is never read.
Placing a source under a subtree
Two sources merging into one EnvironmentConfig, one at the root and one nested under
teams.payments via toFieldPath:
sources:
- name: shared
ref:
namespace: platform
name: shared-config
apiVersion: v1
kind: ConfigMap
- name: payments
toFieldPath: teams.payments
ref:
namespace: platform
name: payments-config
apiVersion: v1
kind: ConfigMap
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
metadata:
name: merged
data:
shared: "yes"
teams:
payments:
owner: payments
[!NOTE] A
ConfigMaporSecrettarget'sdataismap[string]string, so a nested subtree is not representable. It is YAML-encoded into a single string value at the top-level key of the path, so expect one encoded blob per top-level prefix rather than nested keys.When a source combines
toFieldPathwithparse, the nested subtree collapses to one blob:parse.format: Autoalways re-encodes it as YAML, since per-key origin detection no longer applies once the keys are nested away. Setparse.formatexplicitly to override this.
Secrets
A Secret source is base64-decoded on read, so the merge operates on plaintext exactly as it
does for a ConfigMap. A Secret target has every merged value base64-encoded back into
data.
- A
Secretsource requires aSecrettarget. Anything else is a fatal error. Merging secret material into aConfigMapwould write it out in plaintext. stringDatais never written, onlydata. Server-side apply never ownsstringData, so a key removed from the merge would never be removed from theSecret.- Under a namespaced composite, a
Secrettarget whosetarget.namespacedisagrees with the composite's namespace is fatal rather than a warning. Relocating secret material quietly is not safe.
Specification
- Define the XRD's
scope. It decides how the rest of this page behaves, and is the first thing to get right.- Example:
apiVersion: apiextensions.crossplane.io/v2 kind: CompositeResourceDefinition metadata: name: xmergers.resources-merger.fn.canilho.net spec: scope: Cluster # or Namespaced, which is the Crossplane v2 default group: resources-merger.fn.canilho.net names: kind: XMerger plural: xmergers versions: - name: v1alpha1 served: true referenceable: true schema: openAPIV3Schema: type: object properties: spec: type: object -
scope: Clusterscope: Namespacedtarget.namespacehonoured ignored, pinned to the composite's namespace Cross-namespace sources unrestricted needs allowCrossNamespaceandallowedSourceNamespacesCluster-scoped targets such as EnvironmentConfigsupported rejected, a namespaced composite cannot own one - Working manifests for both are in
example/andexample/namespaced/.
- Example:
- Select which is the target resource that will be created/managed by this function.
- Examples:
ConfigMap,EnvironmentConfig, etc. - Using a
ConfigMap:target: namespace: <target-namespace> name: <target-name> apiVersion: v1 kind: ConfigMap
- Examples:
- Identify which resources will be merged into the target resource.
- Two
ConfigMaps and aEnvironmentConfig:sources: - name: <source-name> ref: namespace: <resource-namespace> name: <resource-name> apiVersion: v1 kind: ConfigMap - name: <source-name> ref: namespace: <resource-namespace> name: <resource-name> apiVersion: v1 kind: ConfigMap - name: <source-name> ref: name: <resource-name> apiVersion: apiextensions.crossplane.io/v1beta1 kind: EnvironmentConfig
- Two
- Define what merge strategy should be used through the
Merge'smergeStrategyfield.- Example:
mergeStrategy: ForceMergeObjects parseEmbedded: true
- Example:
- Observe the merged resource.
- Example:
apiVersion: v1 kind: ConfigMap metadata: name: <target-name> namespace: <target-namespace> data: foo: bar
- Example:
[!NOTE]
ConfigMapandEnvironmentConfigresources are used as an example. This function can be used with any Kubernetes resource that contains adatafield in its spec. Do note that the data-type compatibility ofdataspec field should to be taken into account when merging results.
[!TIP] The
Merge'smergeStrategyfield selects how each source merges into the accumulator. The default,ForceMergeObjects, has later sources win.MergeObjectsandMergeObjectsAppendArraysare fill-only: the accumulator wins, so an earlier source takes precedence. See the precedence table below.➤ mergeStrategy (
string)
Value Description ReplaceTop-level keys the later source sets overwrite the same key in the accumulator wholesale; keys it does not set survive unchanged. MergeObjectsDeep merge; existing non-empty values win over later sources. ForceMergeObjectsDeep merge; later sources overwrite existing values. ( default)MergeObjectsAppendArraysMergeObjects, and arrays are appended instead of overwritten.ForceMergeObjectsAppendArraysForceMergeObjects, and arrays are appended instead of overwritten.
| Strategy | Conflicting scalar | Nested map | Arrays | Explicit null from a later source |
|---|---|---|---|---|
ForceMergeObjects (default) |
later source wins | recursive, later wins | replaced | kept |
ForceMergeObjectsAppendArrays |
later source wins | recursive, later wins | appended | kept |
MergeObjects |
earlier source wins | recursive, fill-only | kept | dropped |
MergeObjectsAppendArrays |
earlier source wins | recursive, fill-only | appended | dropped |
Replace |
later source wins | replaced wholesale | replaced | kept |
[!IMPORTANT]
MergeObjectsandMergeObjectsAppendArraysare fill-only: the accumulator wins, so an earlier source takes precedence. To layer overrides on top of a base, which is the usual intent, use the defaultForceMergeObjects.
[!TIP] ➤ parseEmbedded (
boolean)
Value Description trueString values that are YAML mappings are parsed so their contents deep-merge. falseString values are merged as-is. ( default)
[!IMPORTANT] Parsing round-trips a value through a YAML decoder and encoder, which preserves the data but not its formatting. With
parseEmbedded: true:
- A JSON value is re-encoded as YAML. JSON is valid YAML, so
{"name":"svc","port":8080}comes back asname: svc\nport: 8080\n. Set that source'sparse.formattoJSONto keep it as JSON.- Prose containing
:is parsed as a mapping.error: connection refusedbecomes a map, not a string. Scope that source'sparse.keysto the keys that actually hold embedded config to avoid this.- Scalars inside a parsed blob gain quotes:
a: nore-encodes asa: "no". It stays the stringno; yaml.v3 is YAML 1.2 and does not coerce it to a boolean.- Trailing zeros are lost:
3.10re-encodes as3.1.- Key order, indentation, comments and anchors are not preserved. A round-tripped blob comes back alphabetically sorted and 2-space indented even when nothing merged into it.
A top-level value that is not a mapping is never parsed and passes through untouched. A blob containing more than one YAML document with real content is left unparsed and merged as opaque text. A trailing
---with nothing after it but whitespace, comments, or an explicitnullcarries no content, so it does not count as a second document and the blob is still parsed.
[!TIP] ➤ debug (
boolean)
Value Description trueThe function will output debug information. falseThe function will not output debug information. ( default)
Example (local)
[!IMPORTANT] Goal: Merge 2x
ConfigMaps and 2xEnvironmentConfigs into a single resultingConfigMap.
ConfigMap(map-1):data: key1: a key2: bConfigMap(map-2):data: key2: c key4: dEnvironmentConfig(envcfg-1):data: key1: e key6: fEnvironmentConfig(envcfg-2):data: key4: g key5: h
Composition ⚙️
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: function-resources-merger
spec:
compositeTypeRef:
apiVersion: resources-merger.fn.canilho.net/v1alpha1
kind: XMerger
mode: Pipeline
pipeline:
- step: run
functionRef:
name: function-resources-merger
input:
apiVersion: merger.fn.canilho.net/v1beta1
kind: Merge
mergeStrategy: ForceMergeObjects
parseEmbedded: true
target:
apiVersion: v1
kind: ConfigMap
nameFromCompositeFieldPath: spec.appName
namespace: ephemeral
sources:
- name: map-1
ref:
namespace: ephemeral
name: map-1
apiVersion: v1
kind: ConfigMap
- name: map-2
ref:
namespace: ephemeral
name: map-2
apiVersion: v1
kind: ConfigMap
- name: envcfg-1
ref:
name: envcfg-1
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
- name: envcfg-2
ref:
name: envcfg-2
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
XR ⚙️
---
apiVersion: resources-merger.fn.canilho.net/v1alpha1
kind: XMerger
metadata:
name: merger-results-xr
spec:
appName: merged
Function ⚙️
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-resources-merger
annotations:
# This tells crossplane composition render to connect to the function locally.
render.crossplane.io/runtime: Development
spec:
# This is ignored when using the Development runtime.
package: function-resources-merger
- The resulting
ConfigMap(merged):--- apiVersion: v1 kind: ConfigMap data: key1: e key2: c key4: g key5: h key6: f metadata: name: merged namespace: ephemeral annotations: crossplane.io/composition-resource-name: configmap/ephemeral/merged labels: crossplane.io/composite: merger-results-xr ownerReferences: - apiVersion: resources-merger.fn.canilho.net/v1alpha1 kind: XMerger name: merger-results-xr controller: true blockOwnerDeletion: true uid: <xr-uid> ...
Test it out!
- Launch the function locally in a separate shell:
go run . --insecure --debug - Render the cluster scoped example:
cd example && crossplane composition render xr.yaml composition.yaml functions.yaml \ --xrd xrd.yaml \ --required-resources required-resources.yaml \ --crossplane-image xpkg.crossplane.io/crossplane/crossplane:v2.4.0 - Or the namespaced example:
cd example/namespaced && crossplane composition render xr.yaml composition.yaml functions.yaml \ --xrd xrd.yaml \ --required-resources required-resources.yaml \ --crossplane-image xpkg.crossplane.io/crossplane/crossplane:v2.4.0
References
Directories
¶
| Path | Synopsis |
|---|---|
|
input
|
|
|
v1beta1
Package v1beta1 contains the input type for this Function.
|
Package v1beta1 contains the input type for this Function. |
|
internal
|
|
|
merger
Package merger compiles a merge strategy and applies it.
|
Package merger compiles a merge strategy and applies it. |
|
transformer
Package transformer parses embedded config blobs and lowers merged data back to the target's field type.
|
Package transformer parses embedded config blobs and lowers merged data back to the target's field type. |