diff --git a/.gitignore b/.gitignore index 74e42be..50818da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ # Go workspace file go.work +# Crossplane packages +*.xpkg + # ignore AI tools settings/config /.claude CLAUDE.md diff --git a/README.md b/README.md index d418746..241f73b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ spec: toFieldPath: "spec.forProvider.region" transforms: - type: map - map: + map: EU: "eu-north-1" US: "us-east-2" ``` @@ -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 + +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** + +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 @@ -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 diff --git a/connection.go b/connection.go index bc3fff7..de071f2 100644 --- a/connection.go +++ b/connection.go @@ -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 { @@ -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 +} diff --git a/connection_test.go b/connection_test.go index bfe5360..7a41940 100644 --- a/connection_test.go +++ b/connection_test.go @@ -7,18 +7,22 @@ import ( 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/reconciler/managed" - "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + xpresource "github.com/crossplane/crossplane-runtime/v2/pkg/resource" "github.com/crossplane/crossplane-runtime/v2/pkg/resource/fake" "github.com/crossplane/crossplane-runtime/v2/pkg/test" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/json" "k8s.io/utils/ptr" + + "github.com/crossplane/function-sdk-go/resource" + "github.com/crossplane/function-sdk-go/resource/composite" ) func TestExtractConnectionDetails(t *testing.T) { type args struct { - cd resource.Composed + cd xpresource.Composed data managed.ConnectionDetails cfg []v1beta1.ConnectionDetail } @@ -136,3 +140,376 @@ func TestExtractConnectionDetails(t *testing.T) { }) } } + +func TestGetConnectionSecretRef(t *testing.T) { + type args struct { + xr *resource.Composite + input *v1beta1.WriteConnectionSecretToRef + } + type want struct { + ref xpv1.SecretReference + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "XRRefProvidedNoInput": { + reason: "Should use the XR's writeConnectionSecretToRef when no input is provided", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid-456"}, + "spec":{"writeConnectionSecretToRef":{"name":"xr-secret","namespace":"xr-namespace"}} + }`), xr) + return xr + }(), + }, + input: nil, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "xr-secret", + Namespace: "xr-namespace", + }, + }, + }, + "InputProvidedNoXRRef": { + reason: "Should use the provided name and namespace from function input when no XR ref is provided", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid-123"} + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Name: "my-custom-secret", + Namespace: "custom-namespace", + }, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "my-custom-secret", + Namespace: "custom-namespace", + }, + }, + }, + "XRRefAndInputProvided": { + reason: "Should use the XR's writeConnectionSecretToRef even when function input is provided", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid-456"}, + "spec":{"writeConnectionSecretToRef":{"name":"xr-secret","namespace":"xr-namespace"}} + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Name: "my-custom-secret", + Namespace: "custom-namespace", + }, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "xr-secret", + Namespace: "xr-namespace", + }, + }, + }, + "GenerateDefault": { + reason: "Should generate name from XR name when neither input nor XR ref is provided", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"name":"my-xr","namespace":"xr-namespace","uid":"test-uid-789"} + }`), xr) + return xr + }(), + }, + input: nil, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "my-xr-connection", + Namespace: "xr-namespace", + }, + }, + }, + "PatchFromCompositeFieldPath": { + reason: "Should apply patches to transform the secret name using XR metadata", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"name":"my-database","namespace":"production","uid":"test-uid-456"}, + "spec":{"parameters":{"env":"prod"}} + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Patches: []v1beta1.ConnectionSecretPatch{ + { + Type: v1beta1.PatchTypeFromCompositeFieldPath, + Patch: v1beta1.Patch{ + FromFieldPath: ptr.To("metadata.uid"), + ToFieldPath: ptr.To("name"), + Transforms: []v1beta1.Transform{ + { + Type: v1beta1.TransformTypeString, + String: &v1beta1.StringTransform{ + Type: v1beta1.StringTransformTypeFormat, + Format: ptr.To("%s-cool-creds"), + }, + }, + }, + }, + }, + { + Type: v1beta1.PatchTypeFromCompositeFieldPath, + Patch: v1beta1.Patch{ + FromFieldPath: ptr.To("spec.parameters.env"), + ToFieldPath: ptr.To("namespace"), + }, + }, + }, + }, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "test-uid-456-cool-creds", + Namespace: "prod", + }, + }, + }, + "CombineFromComposite": { + reason: "Should apply combine patches to construct secret name from multiple fields", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"name":"db-instance","namespace":"default"}, + "spec":{"parameters":{"appName":"myapp","env":"staging"}} + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Patches: []v1beta1.ConnectionSecretPatch{ + { + Type: v1beta1.PatchTypeCombineFromComposite, + Patch: v1beta1.Patch{ + Combine: &v1beta1.Combine{ + Variables: []v1beta1.CombineVariable{ + {FromFieldPath: "spec.parameters.appName"}, + {FromFieldPath: "spec.parameters.env"}, + }, + Strategy: v1beta1.CombineStrategyString, + String: &v1beta1.StringCombine{ + Format: "%s-%s-cool-creds", + }, + }, + ToFieldPath: ptr.To("name"), + }, + }, + }, + }, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "myapp-staging-cool-creds", + Namespace: "default", // we didn't patch this, but it picks it up from the XR's namespace + }, + }, + }, + "PatchesWithStaticBase": { + reason: "Should apply patches on top of static input values, basically overwriting them. if no patch exists for a field then the static base should be used.", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"name":"my-xr","namespace":"default"}, + "spec":{"targetNamespace":"custom-ns"} + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Name: "base-secret-name", + Namespace: "base-secret-namespace", + Patches: []v1beta1.ConnectionSecretPatch{ + { + Type: v1beta1.PatchTypeFromCompositeFieldPath, + Patch: v1beta1.Patch{ + FromFieldPath: ptr.To("spec.targetNamespace"), + ToFieldPath: ptr.To("namespace"), + }, + }, + }, + }, + }, + want: want{ + ref: xpv1.SecretReference{ + Name: "base-secret-name", // only namespace was patched + Namespace: "custom-ns", + }, + }, + }, + "UnsupportedPatchType": { + reason: "Should return an error when an unsupported patch is provided.", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"name":"my-xr","namespace":"default"}, + }`), xr) + return xr + }(), + }, + input: &v1beta1.WriteConnectionSecretToRef{ + Patches: []v1beta1.ConnectionSecretPatch{ + { + Type: v1beta1.PatchTypeCombineToEnvironment, + }, + }, + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := getConnectionSecretRef(tc.args.xr, tc.args.input) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\ngetConnectionSecretRef(...): -want, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.ref, got); diff != "" { + t.Errorf("%s\ngetConnectionSecretRef(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSupportsConnectionDetails(t *testing.T) { + type args struct { + xr *resource.Composite + } + type want struct { + supportsConnectionDetails bool + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "V1LegacyXRWithoutCrossplaneField": { + reason: "A legacy v1 XR without spec.crossplane field should support connection details", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid"}, + "spec":{"writeConnectionSecretToRef":{"name":"my-secret"}} + }`), xr) + return xr + }(), + }, + }, + want: want{ + supportsConnectionDetails: true, + }, + }, + "V2ModernXRWithCrossplaneField": { + reason: "A v2 XR with spec.crossplane field should not support connection details", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid"}, + "spec":{"crossplane":{"compositionRef":{"name":"my-comp"}}} + }`), xr) + return xr + }(), + }, + }, + want: want{ + supportsConnectionDetails: false, + }, + }, + "V2XRWithWriteConnectionSecretToRefInSchema": { + reason: "A v2 XR that also has writeConnectionSecretToRef (manually added by XR author for compatibility) should still not support connection details based on presence of spec.crossplane", + args: args{ + xr: &resource.Composite{ + Resource: func() *composite.Unstructured { + xr := composite.New() + _ = json.Unmarshal([]byte(`{ + "apiVersion":"example.org/v1", + "kind":"XR", + "metadata":{"uid":"test-uid"}, + "spec":{ + "crossplane":{"compositionRef":{"name":"my-comp"}}, + "writeConnectionSecretToRef":{"name":"my-secret"} + } + }`), xr) + return xr + }(), + }, + }, + want: want{ + supportsConnectionDetails: false, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := supportsConnectionDetails(tc.args.xr) + if got != tc.want.supportsConnectionDetails { + t.Errorf("%s\nsupportsConnectionDetails(...): want %v, got %v", tc.reason, tc.want.supportsConnectionDetails, got) + } + }) + } +} diff --git a/fn.go b/fn.go index a60f755..021749d 100644 --- a/fn.go +++ b/fn.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "maps" "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" @@ -264,6 +265,26 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest desired[resource.Name(t.Name)] = dcd } + // If the XR doesn't support connection details, but we have some connection + // details, we'll transparently create a composed secret resource to store + // the connection details and add it to the set of desired resources. + if !supportsConnectionDetails(oxr) && len(dxr.ConnectionDetails) > 0 { + connectionSecret, err := composeConnectionSecret(oxr, dxr.ConnectionDetails, input.WriteConnectionSecretToRef) + if err != nil { + response.Fatal(rsp, errors.Wrap(err, "cannot compose connection secret")) + return rsp, nil + } + + if connectionSecret != nil { + // Add the connection secret as a composed resource with a special name + // We use a prefix to avoid conflicts with user-defined resource names + composedResourceName := resource.Name(fmt.Sprintf("%s-connection-secret", oxr.Resource.GetName())) + desired[composedResourceName] = connectionSecret + log.Debug("Added connection secret to desired composed resources", "composed-resource-name", composedResourceName, + "secret-name", connectionSecret.Resource.GetName(), "secret-namespace", connectionSecret.Resource.GetNamespace()) + } + } + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) return rsp, nil diff --git a/fn_test.go b/fn_test.go index 4fcf639..90ca4b9 100644 --- a/fn_test.go +++ b/fn_test.go @@ -721,6 +721,80 @@ func TestRunFunction(t *testing.T) { }, }, }, + "ExtractCompositeConnectionDetailsV2XR": { + reason: "Connection details for a v2 XR get exposed in an automatically composed Secret resource.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + WriteConnectionSecretToRef: &v1beta1.WriteConnectionSecretToRef{ + // the name of the automatically composed connection secret + Name: "cool-conn-secret", + }, + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + ConnectionDetails: []v1beta1.ConnectionDetail{ + { + Type: v1beta1.ConnectionDetailTypeFromConnectionSecretKey, + Name: "very", + FromConnectionSecretKey: ptr.To[string]("very"), + }, + }, + }, + }, + }), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + // v2 XR that has spec.crossplane + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","metadata":{"namespace":"default","name":"cool-xr-42"},"spec":{"foo":"bar","crossplane":{}}}`), + }, + Resources: map[string]*fnv1.Resource{ + "cool-resource": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"namespace":"default","name":"cool-42"}}`), + ConnectionDetails: map[string][]byte{ + "very": []byte("secret"), + }, + }, + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","metadata":{"namespace":"default","name":"cool-xr-42"},"spec":{"foo":"bar","crossplane":{}}}`), + ConnectionDetails: map[string][]byte{ + "existing": []byte("supersecretvalue"), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","metadata":{"namespace":"default","name":"cool-xr-42"},"spec":{"foo":"bar","crossplane":{}}}`), + ConnectionDetails: map[string][]byte{ + "existing": []byte("supersecretvalue"), + "very": []byte("secret"), + }, + }, + Resources: map[string]*fnv1.Resource{ + "cool-resource": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"namespace":"default","name":"cool-42"}}`), + }, + // automatically generated connection secret with a name from the function input.writeConnectionSecretToRef + // and base64-encoded data values from the connection details shown above + "cool-xr-42-connection-secret": { + Ready: fnv1.Ready_READY_TRUE, + Resource: resource.MustStructJSON(`{"apiVersion":"v1","kind":"Secret","metadata":{"name":"cool-conn-secret"},"data":{"existing":"c3VwZXJzZWNyZXR2YWx1ZQ==","very":"c2VjcmV0"},"type":"connection.crossplane.io/v1alpha1"}`), + }, + }, + }, + Context: contextWithEnvironment(nil), + }, + }, + }, "PatchToComposite": { reason: "A basic ToCompositeFieldPath patch should work.", args: args{ diff --git a/input/v1beta1/resources.go b/input/v1beta1/resources.go index c86f447..1da06be 100644 --- a/input/v1beta1/resources.go +++ b/input/v1beta1/resources.go @@ -35,4 +35,12 @@ type Resources struct { // Resources is a list of resource templates that will be used when a // composite resource is created. Resources []ComposedTemplate `json:"resources"` + + // WriteConnectionSecretToRef specifies the name and namespace of a Secret + // to which any connection details for this composite resource should be + // written. This field is only used for Crossplane v2 composite resources. + // For v1 composite resources, connection details are returned via the + // RunFunctionResponse and handled by Crossplane core. + // +optional + WriteConnectionSecretToRef *WriteConnectionSecretToRef `json:"writeConnectionSecretToRef,omitempty"` } diff --git a/input/v1beta1/resources_connection.go b/input/v1beta1/resources_connection.go new file mode 100644 index 0000000..47e14aa --- /dev/null +++ b/input/v1beta1/resources_connection.go @@ -0,0 +1,21 @@ +package v1beta1 + +// WriteConnectionSecretToRef specifies a name and namespace for a connection secret. +type WriteConnectionSecretToRef struct { + // Name of the connection secret. If not specified, defaults to {xr-name}-connection. + // +optional + Name string `json:"name,omitempty"` + + // Namespace of the connection secret. If not specified for namespaced XRs, + // Crossplane will default it to the XR's namespace. For cluster-scoped XRs, + // namespace must be explicitly provided. + // +optional + Namespace string `json:"namespace,omitempty"` + + // Patches define transformations to apply to the connection secret reference. + // Patches are only applied from the composite resource to the secret reference. + // Supported patch types: FromCompositeFieldPath, CombineFromComposite. + // ToFieldPath must be either "name" or "namespace". + // +optional + Patches []ConnectionSecretPatch `json:"patches,omitempty"` +} diff --git a/input/v1beta1/resources_patches.go b/input/v1beta1/resources_patches.go index 0c1b334..db42d77 100644 --- a/input/v1beta1/resources_patches.go +++ b/input/v1beta1/resources_patches.go @@ -285,3 +285,28 @@ type StringCombine struct { // https://golang.org/pkg/fmt/ for details. Format string `json:"fmt"` } + +// ConnectionSecretPatch defines a patch to apply to the connection secret reference. +// This uses the same patching logic as resource patches but targets the secret +// reference name and namespace fields. +// +// Only patch types that make sense for connection secrets +// (FromCompositeFieldPath, CombineFromComposite) are allowed. +type ConnectionSecretPatch struct { + Patch `json:",inline"` + + // Type sets the patch type. Only FromCompositeFieldPath and CombineFromComposite + // are supported for connection secret patches. + // +optional + // +kubebuilder:validation:Enum=FromCompositeFieldPath;CombineFromComposite + // +kubebuilder:default=FromCompositeFieldPath + Type PatchType `json:"type,omitempty"` +} + +// GetType returns the patch type. If the type is not set, it returns the default type. +func (csp *ConnectionSecretPatch) GetType() PatchType { + if csp.Type == "" { + return PatchTypeFromCompositeFieldPath + } + return csp.Type +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 5caab40..a037974 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -52,12 +52,12 @@ func (in *CombineVariable) DeepCopy() *CombineVariable { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ComposedPatch) DeepCopyInto(out *ComposedPatch) { *out = *in + in.Patch.DeepCopyInto(&out.Patch) if in.PatchSetName != nil { in, out := &in.PatchSetName, &out.PatchSetName *out = new(string) **out = **in } - in.Patch.DeepCopyInto(&out.Patch) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComposedPatch. @@ -141,6 +141,22 @@ func (in *ConnectionDetail) DeepCopy() *ConnectionDetail { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionSecretPatch) DeepCopyInto(out *ConnectionSecretPatch) { + *out = *in + in.Patch.DeepCopyInto(&out.Patch) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSecretPatch. +func (in *ConnectionSecretPatch) DeepCopy() *ConnectionSecretPatch { + if in == nil { + return nil + } + out := new(ConnectionSecretPatch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConvertTransform) DeepCopyInto(out *ConvertTransform) { *out = *in @@ -441,7 +457,7 @@ func (in *ReadinessCheck) DeepCopyInto(out *ReadinessCheck) { if in.MatchCondition != nil { in, out := &in.MatchCondition, &out.MatchCondition *out = new(MatchConditionReadinessCheck) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -479,6 +495,11 @@ func (in *Resources) DeepCopyInto(out *Resources) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.WriteConnectionSecretToRef != nil { + in, out := &in.WriteConnectionSecretToRef, &out.WriteConnectionSecretToRef + *out = new(WriteConnectionSecretToRef) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resources. @@ -663,3 +684,25 @@ func (in *TypeReference) DeepCopy() *TypeReference { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WriteConnectionSecretToRef) DeepCopyInto(out *WriteConnectionSecretToRef) { + *out = *in + if in.Patches != nil { + in, out := &in.Patches, &out.Patches + *out = make([]ConnectionSecretPatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WriteConnectionSecretToRef. +func (in *WriteConnectionSecretToRef) DeepCopy() *WriteConnectionSecretToRef { + if in == nil { + return nil + } + out := new(WriteConnectionSecretToRef) + in.DeepCopyInto(out) + return out +} diff --git a/package/input/pt.fn.crossplane.io_resources.yaml b/package/input/pt.fn.crossplane.io_resources.yaml index b398708..07edd02 100644 --- a/package/input/pt.fn.crossplane.io_resources.yaml +++ b/package/input/pt.fn.crossplane.io_resources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: resources.pt.fn.crossplane.io spec: group: pt.fn.crossplane.io @@ -1233,7 +1233,7 @@ spec: items: description: |- ReadinessCheck is used to indicate how to tell whether a resource is ready - for consumption + for consumption. properties: fieldPath: description: FieldPath shows the path of the field whose value @@ -1286,6 +1286,384 @@ spec: - name type: object type: array + writeConnectionSecretToRef: + description: |- + WriteConnectionSecretToRef specifies the name and namespace of a Secret + to which any connection details for this composite resource should be + written. This field is only used for Crossplane v2 composite resources. + For v1 composite resources, connection details are returned via the + RunFunctionResponse and handled by Crossplane core. + properties: + name: + description: Name of the connection secret. If not specified, defaults + to {xr-name}-connection. + type: string + namespace: + description: |- + Namespace of the connection secret. If not specified for namespaced XRs, + Crossplane will default it to the XR's namespace. For cluster-scoped XRs, + namespace must be explicitly provided. + type: string + patches: + description: |- + Patches define transformations to apply to the connection secret reference. + Patches are only applied from the composite resource to the secret reference. + Supported patch types: FromCompositeFieldPath, CombineFromComposite. + ToFieldPath must be either "name" or "namespace". + items: + description: |- + ConnectionSecretPatch defines a patch to apply to the connection secret reference. + This uses the same patching logic as resource patches but targets the secret + reference name and namespace fields. + + Only patch types that make sense for connection secrets + (FromCompositeFieldPath, CombineFromComposite) are allowed. + properties: + combine: + description: |- + Combine is the patch configuration for a CombineFromComposite, + CombineToComposite patch. + properties: + strategy: + description: |- + Strategy defines the strategy to use to combine the input variable values. + Currently only string is supported. + enum: + - string + type: string + string: + description: |- + String declares that input variables should be combined into a single + string, using the relevant settings for formatting purposes. + properties: + fmt: + description: |- + Format the input using a Go format string. See + https://golang.org/pkg/fmt/ for details. + type: string + required: + - fmt + type: object + variables: + description: |- + Variables are the list of variables whose values will be retrieved and + combined. + items: + description: |- + A CombineVariable defines the source of a value that is combined with + others to form and patch an output value. Currently, this only supports + retrieving values from a field path. + properties: + fromFieldPath: + description: |- + FromFieldPath is the path of the field on the source whose value is + to be used as input. + type: string + required: + - fromFieldPath + type: object + minItems: 1 + type: array + required: + - strategy + - variables + type: object + fromFieldPath: + description: |- + FromFieldPath is the path of the field on the resource whose value is + to be used as input. Required when type is FromCompositeFieldPath or + ToCompositeFieldPath. + type: string + policy: + description: Policy configures the specifics of patching behaviour. + properties: + fromFieldPath: + description: |- + FromFieldPath specifies how to patch from a field path. The default is + 'Optional', which means the patch will be a no-op if the specified + fromFieldPath does not exist. Use 'Required' to prevent the creation of a + new composed resource until the required path exists. + enum: + - Optional + - Required + type: string + toFieldPath: + description: |- + ToFieldPath specifies how to patch to a field path. The default is + 'Replace', which means the patch will completely replace the target field, + or create it if it does not exist. Use 'MergeObjects' to recursively merge the patch + object with the target object, while keeping target object keys, but overwriting any array values, or use + 'MergeObjectsAppendArrays' to recursively merge the patch object with the target object, while keeping + target object keys and appending any array values to target array values, or use + 'ForceMergeObjects' to recursively merge the patch object with the target object, overwriting + any target object keys, including array values, or use + 'ForceMergeObjectsAppendArrays' to recursively merge the patch object with the target object, + overwriting target object keys, and appending any array values to target array values. + 'MergeObject' is deprecated, use 'MergeObjects' instead, which is functionally identical. + 'AppendArray' is deprecated, use 'ForceMergeObjectsAppendArrays' instead, which is functionally identical. + enum: + - Replace + - MergeObjects + - MergeObjectsAppendArrays + - ForceMergeObjects + - ForceMergeObjectsAppendArrays + - MergeObject + - AppendArray + type: string + type: object + toFieldPath: + description: |- + ToFieldPath is the path of the field on the resource whose value will + be changed with the result of transforms. Leave empty if you'd like to + propagate to the same path as fromFieldPath. + type: string + transforms: + description: |- + Transforms are the list of functions that are used as a FIFO pipe for the + input to be transformed. + items: + description: |- + Transform is a unit of process whose input is transformed into an output with + the supplied configuration. + properties: + convert: + description: Convert is used to cast the input into the + given output type. + properties: + format: + description: |- + The expected input format. + + * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). + Only used during `string -> float64` conversions. + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string -> list` conversions. + + If this property is null, the default conversion is applied. + enum: + - none + - quantity + - json + type: string + toType: + description: ToType is the type of the output of this + transform. + enum: + - string + - int + - int64 + - bool + - float64 + - object + - array + type: string + required: + - toType + type: object + map: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: Map uses the input as a key in the given + map and returns the value. + type: object + match: + description: Match is a more complex version of Map that + matches a list of patterns. + properties: + fallbackTo: + default: Value + description: Determines to what value the transform + should fallback if no pattern matches. + enum: + - Value + - Input + type: string + fallbackValue: + description: |- + The fallback value that should be returned by the transform if now pattern + matches. + x-kubernetes-preserve-unknown-fields: true + patterns: + description: |- + The patterns that should be tested against the input string. + Patterns are tested in order. The value of the first match is used as + result of this transform. + items: + description: |- + MatchTransformPattern is a transform that returns the value that matches a + pattern. + properties: + literal: + description: |- + Literal exactly matches the input string (case sensitive). + Is required if `type` is `literal`. + type: string + regexp: + description: |- + Regexp to match against the input string. + Is required if `type` is `regexp`. + type: string + result: + description: The value that is used as result + of the transform if the pattern matches. + x-kubernetes-preserve-unknown-fields: true + type: + default: literal + description: |- + Type specifies how the pattern matches the input. + + * `literal` - the pattern value has to exactly match (case sensitive) the + input string. This is the default. + + * `regexp` - the pattern treated as a regular expression against + which the input string is tested. Crossplane will throw an error if the + key is not a valid regexp. + enum: + - literal + - regexp + type: string + required: + - result + - type + type: object + type: array + type: object + math: + description: |- + Math is used to transform the input via mathematical operations such as + multiplication. + properties: + clampMax: + description: ClampMax makes sure that the value is + not bigger than the given value. + format: int64 + type: integer + clampMin: + description: ClampMin makes sure that the value is + not smaller than the given value. + format: int64 + type: integer + multiply: + description: Multiply the value. + format: int64 + type: integer + type: + default: Multiply + description: Type of the math transform to be run. + enum: + - Multiply + - ClampMin + - ClampMax + type: string + type: object + string: + description: |- + String is used to transform the input into a string or a different kind + of string. Note that the input does not necessarily need to be a string. + properties: + convert: + description: |- + Optional conversion method to be specified. + `ToUpper` and `ToLower` change the letter case of the input string. + `ToBase64` and `FromBase64` perform a base64 conversion based on the input string. + `ToJson` converts any input value into its raw JSON representation. + `ToSha1`, `ToSha256` and `ToSha512` generate a hash value based on the input + converted to JSON. + enum: + - ToUpper + - ToLower + - ToBase64 + - FromBase64 + - ToJson + - ToSha1 + - ToSha256 + - ToSha512 + type: string + fmt: + description: |- + Format the input using a Go format string. See + https://golang.org/pkg/fmt/ for details. + type: string + join: + description: Join the input strings. + properties: + separator: + description: Separator to join the input strings. + type: string + required: + - separator + type: object + regexp: + description: Extract a match from the input using + a regular expression. + properties: + group: + description: Group number to match. 0 (the default) + matches the entire expression. + type: integer + match: + description: |- + Match string. May optionally include submatches, aka capture groups. + See https://pkg.go.dev/regexp/ for details. + type: string + required: + - match + type: object + replace: + description: Search/Replace applied to the input string. + properties: + replace: + description: The Replace string replaces all occurrences + of the search string. + type: string + search: + description: The Search string to match. + type: string + required: + - replace + - search + type: object + trim: + description: Trim the prefix or suffix from the input + type: string + type: + default: Format + description: Type of the string transform to be run. + enum: + - Format + - Convert + - TrimPrefix + - TrimSuffix + - Regexp + type: string + required: + - type + type: object + type: + description: Type of the transform to be run. + enum: + - map + - match + - math + - string + - convert + type: string + required: + - type + type: object + type: array + type: + default: FromCompositeFieldPath + description: |- + Type sets the patch type. Only FromCompositeFieldPath and CombineFromComposite + are supported for connection secret patches. + enum: + - FromCompositeFieldPath + - CombineFromComposite + type: string + type: object + type: array + type: object required: - resources type: object