Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
# Go workspace file
go.work

# Crossplane packages
*.xpkg

# ignore AI tools settings/config
/.claude
CLAUDE.md
Expand Down
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ spec:
toFieldPath: "spec.forProvider.region"
transforms:
- type: map
map:
map:
EU: "eu-north-1"
US: "us-east-2"
```
Expand Down Expand Up @@ -169,6 +169,76 @@ Starting with Crossplane v1.16.0, the `convert` command in the [Crossplane
CLI][cli-convert] will automatically convert `mergeOptions` to `toFieldPath` for
you.

## XR Connection details

This function handles composite resource connection details differently
depending on if the XR is Crossplane `v1` or `v2` style.

* `v1`: Connection details are returned from the function pipeline and Crossplane
creates a connection secret for the XR/claim.
* `v2`: This function automatically composes a `Secret` containing the connection
details and includes it along with the XR's other composed resources.

A full [connection details guide][docs-connection-details] can be found in the
Crossplane documentation.

### Setting name/namespace

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be helpful to explicitly state that these rules apply only to Crossplane v2-style XRs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, i will add some clarification here, good point!


For v2 XRs, you can control the name and namespace of this connection secret in
a few ways, in order of precedence:

**XR reference:**

If you've manually included a `spec.writeConnectionSecretToRef` in your XR's
schema, this function will use that reference. This can be useful for maintaining
consistency with existing XR configurations.

**Function `input`:**

A `writeConnectionSecretToRef` specified in the function `input` that has at
least one of name or namespace set:

```yaml
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
writeConnectionSecretToRef:
name: my-app-credentials
namespace: production
```

**Default auto generated**

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m sceptical about automatically generating a connection Secret. Creating a Secret is a security and lifecycle significant action, and I think it should require explicit user intent via the options above.

Copy link
Member Author

@jbw976 jbw976 Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for bringing this up, I can see some merit to this point. However, I think it's still a good idea to automatically generate a connection secret because:

  • this was already being done in the v1 flow, the XR would automatically get a connection secret created for it named <XR-uuid>
  • i would argue the user has explicitly specified their intent to have a secret by including connectionDetails for at least 1 of their resources in the composition. If they haven't specified any of those, then no secret will be created
  • this hopefully lowers the friction/changes for users migrating from v1 - if they do nothing with their composition for connection details, it "still works" and a secret is still created for them.

what do you think about that perspective?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I can also see the security concern, I agree it makes sense to default to the v1 behavior here 👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would argue the user has explicitly specified their intent to have a secret by including connectionDetails for at least 1 of their resources in the composition. If they haven't specified any of those, then no secret will be created

This is a valid point :)


If none of the above options are provided, the function generates a name based
on the XR's name (`{xr-name}-connection`) and uses the XR's namespace if it has
one. Note this will not work for cluster scoped XR's because there is no
namespace to store the `Secret` in. You must specify a connection secret
namespace for cluster scoped XRs if you want connection secret functionality.

### Patching secret name/namespace

For v2 XRs, you can also use patches to dynamically construct the secret name or
namespace from XR fields. This is useful when you want the secret name to
include environment-specific information or other metadata:

```yaml
writeConnectionSecretToRef:
patches:
- type: CombineFromComposite
toFieldPath: name
combine:
variables:
- fromFieldPath: metadata.name
- fromFieldPath: spec.parameters.environment
strategy: string
string:
fmt: "%s-%s-credentials"
```

Patches support the same `FromCompositeFieldPath` and `CombineFromComposite`
types available for resource patches (and only those patch types), and can
target either `name` or `namespace` fields.

## Developing this function

This function uses [Go][go], [Docker][docker], and the [Crossplane CLI][cli] to
Expand All @@ -189,9 +259,9 @@ $ crossplane xpkg build -f package --embed-runtime-image=runtime
```

[Crossplane]: https://crossplane.io
[docs-composition]: https://docs.crossplane.io/latest/getting-started/provider-aws-part-2/#create-a-deployment-template
[docs-functions]: https://docs.crossplane.io/latest/concepts/compositions/
[docs-pandt]: https://docs.crossplane.io/latest/guides/function-patch-and-transform/
[docs-connection-details]: https://docs.crossplane.io/latest/guides/connection-details-composition/
[fn-go-templating]: https://github.com/crossplane-contrib/function-go-templating
[#4617]: https://github.com/crossplane/crossplane/issues/4617
[#4746]: https://github.com/crossplane/crossplane/issues/4746
Expand Down
146 changes: 141 additions & 5 deletions connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,38 @@ package main

import (
"github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1"
xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1"
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath"
"github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed"
"github.com/crossplane/crossplane-runtime/v2/pkg/resource"
xpresource "github.com/crossplane/crossplane-runtime/v2/pkg/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/json"

"github.com/crossplane/function-sdk-go/resource"
"github.com/crossplane/function-sdk-go/resource/composed"
)

// ConnectionDetailsExtractor extracts the connection details of a resource.
type ConnectionDetailsExtractor interface {
// ExtractConnection of the supplied resource.
ExtractConnection(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
ExtractConnection(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
}

// A ConnectionDetailsExtractorFn is a function that satisfies
// ConnectionDetailsExtractor.
type ConnectionDetailsExtractorFn func(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
type ConnectionDetailsExtractorFn func(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)

// ExtractConnection of the supplied resource.
func (fn ConnectionDetailsExtractorFn) ExtractConnection(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
func (fn ConnectionDetailsExtractorFn) ExtractConnection(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
return fn(cd, conn, cfg...)
}

// ExtractConnectionDetails extracts XR connection details from the supplied
// composed resource. If no ExtractConfigs are supplied no connection details
// will be returned.
func ExtractConnectionDetails(cd resource.Composed, data managed.ConnectionDetails, cfgs ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
func ExtractConnectionDetails(cd xpresource.Composed, data managed.ConnectionDetails, cfgs ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
out := map[string][]byte{}
for _, cfg := range cfgs {
if err := ValidateConnectionDetail(cfg); err != nil {
Expand Down Expand Up @@ -76,3 +81,134 @@ func fromFieldPath(from runtime.Object, path string) ([]byte, error) {

return json.Marshal(in)
}

// supportsConnectionDetails determines if the given XR supports native/classic
// connection details.
func supportsConnectionDetails(xr *resource.Composite) bool {
// v2 modern XRs don't support connection details. They should have a
// spec.crossplane field, which may be our only indication it's a v2 XR
_, err := xr.Resource.GetValue("spec.crossplane")
return err != nil
}

// composeConnectionSecret creates a Secret composed resource containing the
// provided connection details.
func composeConnectionSecret(xr *resource.Composite, details resource.ConnectionDetails, ref *v1beta1.WriteConnectionSecretToRef) (*resource.DesiredComposed, error) {
if len(details) == 0 {
return nil, nil
}

secret := composed.New()
secret.SetAPIVersion("v1")
secret.SetKind("Secret")

secretRef, err := getConnectionSecretRef(xr, ref)
if err != nil {
return nil, errors.Wrap(err, "cannot generate connection secret reference")
}
secret.SetName(secretRef.Name)
secret.SetNamespace(secretRef.Namespace)

if err := secret.SetValue("data", details); err != nil {
return nil, errors.Wrap(err, "cannot set connection secret data")
}

if err := secret.SetValue("type", xpresource.SecretTypeConnection); err != nil {
return nil, errors.Wrap(err, "cannot set connection secret type")
}

return &resource.DesiredComposed{
Resource: secret,
Ready: resource.ReadyTrue,
}, nil
}

// getConnectionSecretRef creates a connection secret reference from the given
// XR and input. The patches for the reference will be applied before the
// reference is returned.
func getConnectionSecretRef(xr *resource.Composite, input *v1beta1.WriteConnectionSecretToRef) (xpv1.SecretReference, error) {
// Get the base connection secret ref to start with
ref := getBaseConnectionSecretRef(xr, input)

// Apply patches to the base connection secret ref if they've been provided
if input != nil && len(input.Patches) > 0 {
if err := applyConnectionSecretPatches(xr, &ref, input.Patches); err != nil {
return xpv1.SecretReference{}, errors.Wrap(err, "cannot apply connection secret patches")
}
}

return ref, nil
}

// getBaseConnectionSecretRef determines the base connection secret reference
// without any patches. This reference is generated with the following
// precedence:
// 1. xr.spec.writeConnectionSecretToRef - this is no longer automatically added
// to v2 XR schemas, but the community has been adding it manually, so if
// it's present we will use it.
// 2. function input.writeConnectionSecretToRef - if name or namespace is provided
// then the whole ref will be used
// 3. generate the reference from scratch, based on the XR name and namespace
func getBaseConnectionSecretRef(xr *resource.Composite, input *v1beta1.WriteConnectionSecretToRef) xpv1.SecretReference {
// Check if XR author manually added writeConnectionSecretToRef to the XR's
// schema and just use that if it exists
xrRef := xr.Resource.GetWriteConnectionSecretToReference()
if xrRef != nil {
return *xrRef
}

// Use the input values if at least one of name or namespace has been provided
if input != nil && (input.Name != "" || input.Namespace != "") {
return xpv1.SecretReference{Name: input.Name, Namespace: input.Namespace}
}

// Nothing has been provided, so generate a default name using the name of the XR
return xpv1.SecretReference{
Name: xr.Resource.GetName() + "-connection",
Namespace: xr.Resource.GetNamespace(),
}
}

// applyConnectionSecretPatches applies all patches provided on the input to the
// connection secret reference.
func applyConnectionSecretPatches(xr *resource.Composite, ref *xpv1.SecretReference, patches []v1beta1.ConnectionSecretPatch) error {
// Convert the secret reference to an unstructured object so we can pass it to the patching logic
// We use a fake (but reasonable) apiVersion and kind because the unstructured converter requires them.
refObj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "SecretReference",
"name": ref.Name,
"namespace": ref.Namespace,
},
}

for i, patch := range patches {
switch patch.GetType() { //nolint:exhaustive // we only care about the patch types we support, everything else is an error
case v1beta1.PatchTypeFromCompositeFieldPath:
if err := ApplyFromFieldPathPatch(&patch, xr.Resource, refObj); err != nil {
// we got an error, but if the patch policy is Optional then just skip this patch
if patch.GetPolicy().GetFromFieldPathPolicy() == v1beta1.FromFieldPathPolicyOptional {
continue
}
return errors.Wrapf(err, "cannot apply patch type %s at index %d", patch.GetType(), i)
}
case v1beta1.PatchTypeCombineFromComposite:
if err := ApplyCombineFromVariablesPatch(&patch, xr.Resource, refObj); err != nil {
return errors.Wrapf(err, "cannot apply patch type %s at index %d", patch.GetType(), i)
}
default:
return errors.Errorf("unsupported patch type %s at index %d", patch.GetType(), i)
}
}

// Extract the patched values and return them on the reference
if name, ok := refObj.Object["name"].(string); ok {
ref.Name = name
}
if namespace, ok := refObj.Object["namespace"].(string); ok {
ref.Namespace = namespace
}

return nil
}
Loading
Loading