Skip to content

Commit 51ffd1c

Browse files
authored
Merge pull request #278 from jbw976/v2-conn-deets
feat: automatic connection details support for v2 XRs
2 parents 18fb9e7 + 9057ff2 commit 51ffd1c

File tree

11 files changed

+1169
-13
lines changed

11 files changed

+1169
-13
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
# Go workspace file
2121
go.work
2222

23+
# Crossplane packages
24+
*.xpkg
25+
2326
# ignore AI tools settings/config
2427
/.claude
2528
CLAUDE.md

README.md

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ spec:
3636
toFieldPath: "spec.forProvider.region"
3737
transforms:
3838
- type: map
39-
map:
39+
map:
4040
EU: "eu-north-1"
4141
US: "us-east-2"
4242
```
@@ -169,6 +169,76 @@ Starting with Crossplane v1.16.0, the `convert` command in the [Crossplane
169169
CLI][cli-convert] will automatically convert `mergeOptions` to `toFieldPath` for
170170
you.
171171

172+
## XR Connection details
173+
174+
This function handles composite resource connection details differently
175+
depending on if the XR is Crossplane `v1` or `v2` style.
176+
177+
* `v1`: Connection details are returned from the function pipeline and Crossplane
178+
creates a connection secret for the XR/claim.
179+
* `v2`: This function automatically composes a `Secret` containing the connection
180+
details and includes it along with the XR's other composed resources.
181+
182+
A full [connection details guide][docs-connection-details] can be found in the
183+
Crossplane documentation.
184+
185+
### Setting name/namespace
186+
187+
For v2 XRs, you can control the name and namespace of this connection secret in
188+
a few ways, in order of precedence:
189+
190+
**XR reference:**
191+
192+
If you've manually included a `spec.writeConnectionSecretToRef` in your XR's
193+
schema, this function will use that reference. This can be useful for maintaining
194+
consistency with existing XR configurations.
195+
196+
**Function `input`:**
197+
198+
A `writeConnectionSecretToRef` specified in the function `input` that has at
199+
least one of name or namespace set:
200+
201+
```yaml
202+
input:
203+
apiVersion: pt.fn.crossplane.io/v1beta1
204+
kind: Resources
205+
writeConnectionSecretToRef:
206+
name: my-app-credentials
207+
namespace: production
208+
```
209+
210+
**Default auto generated**
211+
212+
If none of the above options are provided, the function generates a name based
213+
on the XR's name (`{xr-name}-connection`) and uses the XR's namespace if it has
214+
one. Note this will not work for cluster scoped XR's because there is no
215+
namespace to store the `Secret` in. You must specify a connection secret
216+
namespace for cluster scoped XRs if you want connection secret functionality.
217+
218+
### Patching secret name/namespace
219+
220+
For v2 XRs, you can also use patches to dynamically construct the secret name or
221+
namespace from XR fields. This is useful when you want the secret name to
222+
include environment-specific information or other metadata:
223+
224+
```yaml
225+
writeConnectionSecretToRef:
226+
patches:
227+
- type: CombineFromComposite
228+
toFieldPath: name
229+
combine:
230+
variables:
231+
- fromFieldPath: metadata.name
232+
- fromFieldPath: spec.parameters.environment
233+
strategy: string
234+
string:
235+
fmt: "%s-%s-credentials"
236+
```
237+
238+
Patches support the same `FromCompositeFieldPath` and `CombineFromComposite`
239+
types available for resource patches (and only those patch types), and can
240+
target either `name` or `namespace` fields.
241+
172242
## Developing this function
173243

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

191261
[Crossplane]: https://crossplane.io
192-
[docs-composition]: https://docs.crossplane.io/latest/getting-started/provider-aws-part-2/#create-a-deployment-template
193262
[docs-functions]: https://docs.crossplane.io/latest/concepts/compositions/
194263
[docs-pandt]: https://docs.crossplane.io/latest/guides/function-patch-and-transform/
264+
[docs-connection-details]: https://docs.crossplane.io/latest/guides/connection-details-composition/
195265
[fn-go-templating]: https://github.com/crossplane-contrib/function-go-templating
196266
[#4617]: https://github.com/crossplane/crossplane/issues/4617
197267
[#4746]: https://github.com/crossplane/crossplane/issues/4746

connection.go

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,38 @@ package main
22

33
import (
44
"github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1"
5+
xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1"
56
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
67
"github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath"
78
"github.com/crossplane/crossplane-runtime/v2/pkg/reconciler/managed"
8-
"github.com/crossplane/crossplane-runtime/v2/pkg/resource"
9+
xpresource "github.com/crossplane/crossplane-runtime/v2/pkg/resource"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
911
"k8s.io/apimachinery/pkg/runtime"
1012
"k8s.io/apimachinery/pkg/util/json"
13+
14+
"github.com/crossplane/function-sdk-go/resource"
15+
"github.com/crossplane/function-sdk-go/resource/composed"
1116
)
1217

1318
// ConnectionDetailsExtractor extracts the connection details of a resource.
1419
type ConnectionDetailsExtractor interface {
1520
// ExtractConnection of the supplied resource.
16-
ExtractConnection(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
21+
ExtractConnection(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
1722
}
1823

1924
// A ConnectionDetailsExtractorFn is a function that satisfies
2025
// ConnectionDetailsExtractor.
21-
type ConnectionDetailsExtractorFn func(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
26+
type ConnectionDetailsExtractorFn func(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error)
2227

2328
// ExtractConnection of the supplied resource.
24-
func (fn ConnectionDetailsExtractorFn) ExtractConnection(cd resource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
29+
func (fn ConnectionDetailsExtractorFn) ExtractConnection(cd xpresource.Composed, conn managed.ConnectionDetails, cfg ...v1beta1.ConnectionDetail) (managed.ConnectionDetails, error) {
2530
return fn(cd, conn, cfg...)
2631
}
2732

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

7782
return json.Marshal(in)
7883
}
84+
85+
// supportsConnectionDetails determines if the given XR supports native/classic
86+
// connection details.
87+
func supportsConnectionDetails(xr *resource.Composite) bool {
88+
// v2 modern XRs don't support connection details. They should have a
89+
// spec.crossplane field, which may be our only indication it's a v2 XR
90+
_, err := xr.Resource.GetValue("spec.crossplane")
91+
return err != nil
92+
}
93+
94+
// composeConnectionSecret creates a Secret composed resource containing the
95+
// provided connection details.
96+
func composeConnectionSecret(xr *resource.Composite, details resource.ConnectionDetails, ref *v1beta1.WriteConnectionSecretToRef) (*resource.DesiredComposed, error) {
97+
if len(details) == 0 {
98+
return nil, nil
99+
}
100+
101+
secret := composed.New()
102+
secret.SetAPIVersion("v1")
103+
secret.SetKind("Secret")
104+
105+
secretRef, err := getConnectionSecretRef(xr, ref)
106+
if err != nil {
107+
return nil, errors.Wrap(err, "cannot generate connection secret reference")
108+
}
109+
secret.SetName(secretRef.Name)
110+
secret.SetNamespace(secretRef.Namespace)
111+
112+
if err := secret.SetValue("data", details); err != nil {
113+
return nil, errors.Wrap(err, "cannot set connection secret data")
114+
}
115+
116+
if err := secret.SetValue("type", xpresource.SecretTypeConnection); err != nil {
117+
return nil, errors.Wrap(err, "cannot set connection secret type")
118+
}
119+
120+
return &resource.DesiredComposed{
121+
Resource: secret,
122+
Ready: resource.ReadyTrue,
123+
}, nil
124+
}
125+
126+
// getConnectionSecretRef creates a connection secret reference from the given
127+
// XR and input. The patches for the reference will be applied before the
128+
// reference is returned.
129+
func getConnectionSecretRef(xr *resource.Composite, input *v1beta1.WriteConnectionSecretToRef) (xpv1.SecretReference, error) {
130+
// Get the base connection secret ref to start with
131+
ref := getBaseConnectionSecretRef(xr, input)
132+
133+
// Apply patches to the base connection secret ref if they've been provided
134+
if input != nil && len(input.Patches) > 0 {
135+
if err := applyConnectionSecretPatches(xr, &ref, input.Patches); err != nil {
136+
return xpv1.SecretReference{}, errors.Wrap(err, "cannot apply connection secret patches")
137+
}
138+
}
139+
140+
return ref, nil
141+
}
142+
143+
// getBaseConnectionSecretRef determines the base connection secret reference
144+
// without any patches. This reference is generated with the following
145+
// precedence:
146+
// 1. xr.spec.writeConnectionSecretToRef - this is no longer automatically added
147+
// to v2 XR schemas, but the community has been adding it manually, so if
148+
// it's present we will use it.
149+
// 2. function input.writeConnectionSecretToRef - if name or namespace is provided
150+
// then the whole ref will be used
151+
// 3. generate the reference from scratch, based on the XR name and namespace
152+
func getBaseConnectionSecretRef(xr *resource.Composite, input *v1beta1.WriteConnectionSecretToRef) xpv1.SecretReference {
153+
// Check if XR author manually added writeConnectionSecretToRef to the XR's
154+
// schema and just use that if it exists
155+
xrRef := xr.Resource.GetWriteConnectionSecretToReference()
156+
if xrRef != nil {
157+
return *xrRef
158+
}
159+
160+
// Use the input values if at least one of name or namespace has been provided
161+
if input != nil && (input.Name != "" || input.Namespace != "") {
162+
return xpv1.SecretReference{Name: input.Name, Namespace: input.Namespace}
163+
}
164+
165+
// Nothing has been provided, so generate a default name using the name of the XR
166+
return xpv1.SecretReference{
167+
Name: xr.Resource.GetName() + "-connection",
168+
Namespace: xr.Resource.GetNamespace(),
169+
}
170+
}
171+
172+
// applyConnectionSecretPatches applies all patches provided on the input to the
173+
// connection secret reference.
174+
func applyConnectionSecretPatches(xr *resource.Composite, ref *xpv1.SecretReference, patches []v1beta1.ConnectionSecretPatch) error {
175+
// Convert the secret reference to an unstructured object so we can pass it to the patching logic
176+
// We use a fake (but reasonable) apiVersion and kind because the unstructured converter requires them.
177+
refObj := &unstructured.Unstructured{
178+
Object: map[string]any{
179+
"apiVersion": "v1",
180+
"kind": "SecretReference",
181+
"name": ref.Name,
182+
"namespace": ref.Namespace,
183+
},
184+
}
185+
186+
for i, patch := range patches {
187+
switch patch.GetType() { //nolint:exhaustive // we only care about the patch types we support, everything else is an error
188+
case v1beta1.PatchTypeFromCompositeFieldPath:
189+
if err := ApplyFromFieldPathPatch(&patch, xr.Resource, refObj); err != nil {
190+
// we got an error, but if the patch policy is Optional then just skip this patch
191+
if patch.GetPolicy().GetFromFieldPathPolicy() == v1beta1.FromFieldPathPolicyOptional {
192+
continue
193+
}
194+
return errors.Wrapf(err, "cannot apply patch type %s at index %d", patch.GetType(), i)
195+
}
196+
case v1beta1.PatchTypeCombineFromComposite:
197+
if err := ApplyCombineFromVariablesPatch(&patch, xr.Resource, refObj); err != nil {
198+
return errors.Wrapf(err, "cannot apply patch type %s at index %d", patch.GetType(), i)
199+
}
200+
default:
201+
return errors.Errorf("unsupported patch type %s at index %d", patch.GetType(), i)
202+
}
203+
}
204+
205+
// Extract the patched values and return them on the reference
206+
if name, ok := refObj.Object["name"].(string); ok {
207+
ref.Name = name
208+
}
209+
if namespace, ok := refObj.Object["namespace"].(string); ok {
210+
ref.Namespace = namespace
211+
}
212+
213+
return nil
214+
}

0 commit comments

Comments
 (0)