diff --git a/examples/selector_with_transforms/README.md b/examples/selector_with_transforms/README.md new file mode 100644 index 0000000..a350d94 --- /dev/null +++ b/examples/selector_with_transforms/README.md @@ -0,0 +1,55 @@ +# Example manifests + +You can run your function locally and test it using `crossplane beta render` +with these example manifests. + +This example demonstrates four different transform types on label selectors, +each pulling from different fields of the composite resource: + +1. **Regexp group extract** — `spec.region` (`us-east-1-abc`) → capture group 1 + → `us-east-1` +2. **Regexp replace with backreferences** — + `metadata.labels[crossplane.io/claim-namespace]` (`team-alpha`) → + `${1}-environment-config` → `alpha-environment-config` +3. **Map transform** — `spec.tier` (`production`) → mapped to `prod` +4. **Convert to lowercase** — `spec.priority` (`CRITICAL`) → `critical` + +Together they select the EnvironmentConfig labeled `region: us-east-1`, +`config: alpha-environment-config`, `tier: prod`, `priority: critical`. + +```shell +# Run the function locally +$ go run . --insecure --debug +``` + +```shell +# Then, in another terminal, call it with these example manifests +$ crossplane render \ + --extra-resources selector_with_transforms/environmentConfigs.yaml \ + --include-context \ + selector_with_transforms/xr.yaml selector_with_transforms/composition.yaml selector_with_transforms/functions.yaml +--- +apiVersion: example.crossplane.io/v1 +kind: XR +metadata: + name: example-xr +status: + conditions: + - lastTransitionTime: "2024-01-01T00:00:00Z" + reason: Available + status: "True" + type: Ready + fromEnv: https://us-east-1.example.com + team: alpha +--- +apiVersion: render.crossplane.io/v1beta1 +fields: + apiextensions.crossplane.io/environment: + apiVersion: internal.crossplane.io/v1alpha1 + kind: Environment + region: + endpoint: https://us-east-1.example.com + name: us-east-1 + team: alpha +kind: Context +``` diff --git a/examples/selector_with_transforms/composition.yaml b/examples/selector_with_transforms/composition.yaml new file mode 100644 index 0000000..bd64d96 --- /dev/null +++ b/examples/selector_with_transforms/composition.yaml @@ -0,0 +1,81 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: function-environment-configs +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Selector + selector: + mode: Single + matchLabels: + # 1. Regexp group extract: + # "us-east-1-abc" → extract group 1 → "us-east-1" + - key: region + valueFromFieldPath: spec.region + transforms: + - type: string + string: + type: Regexp + regexp: + match: "^([^-]+-[^-]+-[0-9]+)" + group: 1 + + # 2. Regexp replace with backreferences: + # "team-alpha" → "${1}-environment-config" → "alpha-environment-config" + - key: config + valueFromFieldPath: "metadata.labels[crossplane.io/claim-namespace]" + transforms: + - type: string + string: + type: Regexp + regexp: + match: "^team-(.+)$" + replace: "${1}-environment-config" + + # 3. Map transform: + # "production" → "prod" + - key: tier + valueFromFieldPath: spec.tier + transforms: + - type: map + map: + production: "prod" + staging: "stage" + development: "dev" + + # 4. Convert to lowercase: + # "CRITICAL" → "critical" + - key: priority + valueFromFieldPath: spec.priority + transforms: + - type: string + string: + type: Convert + convert: ToLower + - step: go-templating + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: example.crossplane.io/v1 + kind: XR + status: + fromEnv: {{ index .context "apiextensions.crossplane.io/environment" "region" "endpoint" }} + team: {{ index .context "apiextensions.crossplane.io/environment" "team" }} diff --git a/examples/selector_with_transforms/environmentConfigs.yaml b/examples/selector_with_transforms/environmentConfigs.yaml new file mode 100644 index 0000000..a2d89dd --- /dev/null +++ b/examples/selector_with_transforms/environmentConfigs.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: env-us-east-1-alpha-prod-critical + labels: + region: "us-east-1" + config: "alpha-environment-config" + tier: "prod" + priority: "critical" +data: + region: + name: us-east-1 + endpoint: https://us-east-1.example.com + team: alpha +--- +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: env-us-east-1-beta-prod-critical + labels: + region: "us-east-1" + config: "beta-environment-config" + tier: "prod" + priority: "critical" +data: + region: + name: us-east-1 + endpoint: https://us-east-1.example.com + team: beta +--- +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: env-eu-west-1-alpha-stage-low + labels: + region: "eu-west-1" + config: "alpha-environment-config" + tier: "stage" + priority: "low" +data: + region: + name: eu-west-1 + endpoint: https://eu-west-1.example.com + team: alpha diff --git a/examples/selector_with_transforms/functions.yaml b/examples/selector_with_transforms/functions.yaml new file mode 100644 index 0000000..336e574 --- /dev/null +++ b/examples/selector_with_transforms/functions.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-environment-configs + annotations: + # This tells crossplane beta render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + # This is ignored when using the Development runtime. + package: xpkg.upbound.io/crossplane-contrib/function-environment-configs:v0.4.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating +spec: + package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.10.0 diff --git a/examples/selector_with_transforms/provider.yaml b/examples/selector_with_transforms/provider.yaml new file mode 100644 index 0000000..8a15317 --- /dev/null +++ b/examples/selector_with_transforms/provider.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-nop +spec: + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.4.0 + ignoreCrossplaneConstraints: true diff --git a/examples/selector_with_transforms/xr.yaml b/examples/selector_with_transforms/xr.yaml new file mode 100644 index 0000000..0cd5e6a --- /dev/null +++ b/examples/selector_with_transforms/xr.yaml @@ -0,0 +1,10 @@ +apiVersion: example.crossplane.io/v1 +kind: XR +metadata: + name: example-xr + labels: + crossplane.io/claim-namespace: team-alpha +spec: + region: us-east-1-abc + tier: production + priority: CRITICAL diff --git a/examples/selector_with_transforms/xrd.yaml b/examples/selector_with_transforms/xrd.yaml new file mode 100644 index 0000000..c65a3d8 --- /dev/null +++ b/examples/selector_with_transforms/xrd.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xrs.example.crossplane.io +spec: + group: example.crossplane.io + names: + kind: XR + plural: xrs + connectionSecretKeys: + - test + versions: + - name: v1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + tier: + type: string + priority: + type: string + status: + type: object + properties: + fromEnv: + type: string + team: + type: string diff --git a/fn.go b/fn.go index 0cb2c4f..b1face3 100644 --- a/fn.go +++ b/fn.go @@ -7,11 +7,7 @@ import ( "reflect" "sort" - "google.golang.org/protobuf/types/known/structpb" - extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - + "github.com/crossplane-contrib/function-environment-configs/input/v1beta1" "github.com/crossplane/crossplane-runtime/pkg/errors" "github.com/crossplane/crossplane-runtime/pkg/fieldpath" "github.com/crossplane/crossplane-runtime/pkg/logging" @@ -19,8 +15,10 @@ import ( "github.com/crossplane/function-sdk-go/request" "github.com/crossplane/function-sdk-go/resource" "github.com/crossplane/function-sdk-go/response" - - "github.com/crossplane-contrib/function-environment-configs/input/v1beta1" + "google.golang.org/protobuf/types/known/structpb" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" ) const ( @@ -304,14 +302,22 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require // TODO validate value not to be nil matchLabels[selector.Key] = *selector.Value case v1beta1.EnvironmentSourceSelectorLabelMatcherTypeFromCompositeFieldPath: - value, err := fieldpath.Pave(xr.Resource.Object).GetString(*selector.ValueFromFieldPath) + val, err := fieldpath.Pave(xr.Resource.Object).GetValue(*selector.ValueFromFieldPath) if err != nil { if !selector.FromFieldPathIsOptional() { return nil, errors.Wrapf(err, "cannot get value from field path %q", *selector.ValueFromFieldPath) } continue } - matchLabels[selector.Key] = value + // Apply transforms if any are specified. + if len(selector.Transforms) > 0 { + val, err = ResolveTransforms(selector.Transforms, val) + if err != nil { + return nil, errors.Wrapf(err, "cannot apply transforms for label %q from field path %q", selector.Key, *selector.ValueFromFieldPath) + } + } + // Labels are always strings. + matchLabels[selector.Key] = fmt.Sprintf("%v", val) } } if len(matchLabels) == 0 { diff --git a/fn_test.go b/fn_test.go index 32228e1..daca5e2 100644 --- a/fn_test.go +++ b/fn_test.go @@ -4,6 +4,11 @@ import ( "context" "testing" + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/pkg/logging" + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" + "github.com/crossplane/function-sdk-go/resource" + "github.com/crossplane/function-sdk-go/response" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/protobuf/encoding/protojson" @@ -11,12 +16,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/utils/ptr" - - "github.com/crossplane/crossplane-runtime/pkg/fieldpath" - "github.com/crossplane/crossplane-runtime/pkg/logging" - fnv1 "github.com/crossplane/function-sdk-go/proto/v1" - "github.com/crossplane/function-sdk-go/resource" - "github.com/crossplane/function-sdk-go/response" ) func TestRunFunction(t *testing.T) { @@ -774,6 +773,438 @@ func TestRunFunction(t *testing.T) { }, }, }, + "SelectorWithStringTransform": { + reason: "The Function should apply string transforms to the value from the field path before using it as a label selector", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": { + "region": "us-east-1-abc" + } + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "region", + "valueFromFieldPath": "spec.region", + "fromFieldPathPolicy": "Required", + "transforms": [ + { + "type": "string", + "string": { + "type": "Regexp", + "regexp": { + "match": "^([^-]+-[^-]+-[^-]+)", + "group": 1 + } + } + } + ] + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "environment-config-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{ + "region": "us-east-1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "SelectorWithChainedTransforms": { + reason: "The Function should apply multiple chained transforms sequentially", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": { + "envLabel": "My-Prefix-Value" + } + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "env", + "valueFromFieldPath": "spec.envLabel", + "fromFieldPathPolicy": "Required", + "transforms": [ + { + "type": "string", + "string": { + "type": "TrimPrefix", + "trim": "My-Prefix-" + } + }, + { + "type": "string", + "string": { + "type": "Convert", + "convert": "ToLower" + } + } + ] + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "environment-config-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{ + "env": "value", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "SelectorWithTransformOnNonStringField": { + reason: "The Function should handle non-string field values (e.g. boolean) and convert them to label strings", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": { + "isProduction": true + } + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "production", + "valueFromFieldPath": "spec.isProduction", + "fromFieldPathPolicy": "Required" + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "environment-config-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{ + "production": "true", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "SelectorWithTransformOptionalFieldMissing": { + reason: "The Function should skip the selector when an optional field path is missing, even if transforms are configured", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": {} + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "env", + "valueFromFieldPath": "spec.missingField", + "fromFieldPathPolicy": "Optional", + "transforms": [ + { + "type": "string", + "string": { + "type": "Convert", + "convert": "ToUpper" + } + } + ] + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ExtraResources: map[string]*fnv1.ResourceSelector{}}, + }, + }, + }, + "SelectorWithMetadataLabelFieldPath": { + reason: "The Function should resolve bracket notation field paths like metadata.labels[crossplane.io/claim-namespace]", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr", + "labels": { + "crossplane.io/claim-namespace": "team-alpha" + } + }, + "spec": { + "region": "us-east-1-abc" + } + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "region", + "valueFromFieldPath": "spec.region", + "fromFieldPathPolicy": "Required", + "transforms": [ + { + "type": "string", + "string": { + "type": "Regexp", + "regexp": { + "match": "^([^-]+-[^-]+-[0-9]+)", + "group": 1 + } + } + } + ] + }, + { + "key": "namespace", + "valueFromFieldPath": "metadata.labels[crossplane.io/claim-namespace]", + "fromFieldPathPolicy": "Required", + "transforms": [ + { + "type": "string", + "string": { + "type": "TrimPrefix", + "trim": "team-" + } + } + ] + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "environment-config-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{ + "region": "us-east-1", + "namespace": "alpha", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "SelectorWithTransformRequiredFieldMissing": { + reason: "The Function should return a fatal result when a required field path is missing, even if transforms are configured", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": {} + }`), + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "environmentConfigs": [ + { + "type": "Selector", + "selector": { + "mode": "Single", + "matchLabels": [ + { + "key": "env", + "valueFromFieldPath": "spec.missingField", + "fromFieldPathPolicy": "Required", + "transforms": [ + { + "type": "string", + "string": { + "type": "Convert", + "convert": "ToUpper" + } + } + ] + } + ] + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Target: ptr.To(fnv1.Target_TARGET_COMPOSITE), + }, + }, + }, + }, + }, } for name, tc := range cases { diff --git a/input/v1beta1/composition_environment.go b/input/v1beta1/composition_environment.go index ee3168a..75e1913 100644 --- a/input/v1beta1/composition_environment.go +++ b/input/v1beta1/composition_environment.go @@ -220,6 +220,13 @@ type EnvironmentSourceSelectorLabelMatcher struct { // Value specifies a literal label value. Value *string `json:"value,omitempty"` + + // Transforms is an optional list of transforms to apply to the value + // resolved from the composite resource before using it for label matching. + // Transforms are applied sequentially. Only applicable when type is + // FromCompositeFieldPath. + // +optional + Transforms []Transform `json:"transforms,omitempty"` } // FromFieldPathIsOptional returns true if the FromFieldPathPolicy is set to diff --git a/input/v1beta1/transforms.go b/input/v1beta1/transforms.go new file mode 100644 index 0000000..a95b2d1 --- /dev/null +++ b/input/v1beta1/transforms.go @@ -0,0 +1,208 @@ +package v1beta1 + +import ( + "encoding/json" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// TransformType is the type of a transform. +type TransformType string + +// Accepted TransformTypes. +const ( + TransformTypeMap TransformType = "map" + TransformTypeMatch TransformType = "match" + TransformTypeString TransformType = "string" +) + +// Transform is a unit of process whose input is transformed into an output with +// the supplied configuration. +type Transform struct { + // Type of the transform to be run. + // +kubebuilder:validation:Enum=map;match;string + Type TransformType `json:"type"` + + // Map uses the input as a key in the given map and returns the value. + // +optional + Map *MapTransform `json:"map,omitempty"` + + // Match is a more complex version of Map that matches a list of patterns. + // +optional + Match *MatchTransform `json:"match,omitempty"` + + // 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. + // +optional + String *StringTransform `json:"string,omitempty"` +} + +// MapTransform returns a value for the input from the given map. +type MapTransform struct { + // Pairs is the map that will be used for transform. + // +optional + Pairs map[string]extv1.JSON `json:",inline"` +} + +// NOTE: The Kubernetes JSON decoder doesn't seem to like inlining a map +// into a struct - doing so results in a seemingly successful unmarshal of the +// data, but an empty map. We must keep the ,inline tag nevertheless in order to +// trick the CRD generator into thinking MapTransform is an arbitrary map (i.e. +// generating a validation schema with string additionalProperties), but the +// actual marshalling is handled by the marshal methods below. + +// UnmarshalJSON into this MapTransform. +func (m *MapTransform) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &m.Pairs) +} + +// MarshalJSON from this MapTransform. +func (m *MapTransform) MarshalJSON() ([]byte, error) { + return json.Marshal(m.Pairs) +} + +// MatchFallbackTo defines how a match operation will fallback. +type MatchFallbackTo string + +// Valid MatchFallbackTo. +const ( + MatchFallbackToTypeValue MatchFallbackTo = "Value" + MatchFallbackToTypeInput MatchFallbackTo = "Input" +) + +// MatchTransform is a more complex version of a map transform that matches a +// list of patterns. +type MatchTransform struct { + // 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. + Patterns []MatchTransformPattern `json:"patterns,omitempty"` + + // The fallback value that should be returned by the transform if no pattern + // matches. + FallbackValue extv1.JSON `json:"fallbackValue,omitempty"` + + // Determines to what value the transform should fallback if no pattern matches. + // +optional + // +kubebuilder:validation:Enum=Value;Input + // +kubebuilder:default=Value + FallbackTo MatchFallbackTo `json:"fallbackTo,omitempty"` +} + +// MatchTransformPatternType defines the type of a MatchTransformPattern. +type MatchTransformPatternType string + +// Valid MatchTransformPatternTypes. +const ( + MatchTransformPatternTypeLiteral MatchTransformPatternType = "literal" + MatchTransformPatternTypeRegexp MatchTransformPatternType = "regexp" +) + +// MatchTransformPattern is a transform that returns the value that matches a +// pattern. +type MatchTransformPattern struct { + // 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. + // + // +kubebuilder:validation:Enum=literal;regexp + // +kubebuilder:default=literal + Type MatchTransformPatternType `json:"type"` + + // Literal exactly matches the input string (case sensitive). + // Is required if `type` is `literal`. + Literal *string `json:"literal,omitempty"` + + // Regexp to match against the input string. + // Is required if `type` is `regexp`. + Regexp *string `json:"regexp,omitempty"` + + // The value that is used as result of the transform if the pattern matches. + Result extv1.JSON `json:"result"` +} + +// StringTransformType transforms a string. +type StringTransformType string + +// Accepted StringTransformTypes. +const ( + StringTransformTypeFormat StringTransformType = "Format" // Default + StringTransformTypeConvert StringTransformType = "Convert" + StringTransformTypeTrimPrefix StringTransformType = "TrimPrefix" + StringTransformTypeTrimSuffix StringTransformType = "TrimSuffix" + StringTransformTypeRegexp StringTransformType = "Regexp" + StringTransformTypeReplace StringTransformType = "Replace" +) + +// StringConversionType converts a string. +type StringConversionType string + +// Accepted StringConversionTypes. +const ( + StringConversionTypeToUpper StringConversionType = "ToUpper" + StringConversionTypeToLower StringConversionType = "ToLower" +) + +// StringTransform returns a string given the supplied input. +type StringTransform struct { + // Type of the string transform to be run. + // +kubebuilder:validation:Enum=Format;Convert;TrimPrefix;TrimSuffix;Regexp;Replace + // +kubebuilder:default=Format + Type StringTransformType `json:"type"` + + // Format the input using a Go format string. See + // https://golang.org/pkg/fmt/ for details. + // +optional + Format *string `json:"fmt,omitempty"` + + // Optional conversion method to be specified. + // `ToUpper` and `ToLower` change the letter case of the input string. + // +optional + // +kubebuilder:validation:Enum=ToUpper;ToLower + Convert *StringConversionType `json:"convert,omitempty"` + + // Trim the prefix or suffix from the input + // +optional + Trim *string `json:"trim,omitempty"` + + // Extract a match from the input using a regular expression. + // +optional + Regexp *StringTransformRegexp `json:"regexp,omitempty"` + + // Search/Replace applied to the input string. + // +optional + Replace *StringTransformReplace `json:"replace,omitempty"` +} + +// StringTransformRegexp extracts a match from the input using a regular +// expression. +type StringTransformRegexp struct { + // Match string. May optionally include submatches, aka capture groups. + // See https://pkg.go.dev/regexp/ for details. + Match string `json:"match"` + + // Group number to match. 0 (the default) matches the entire expression. + // +optional + Group *int `json:"group,omitempty"` + + // Replace is the replacement string applied using regexp backreferences. + // When set, the transform uses ReplaceAllString with backreference support + // (e.g. ${1}, ${2}) instead of extracting a single group. + // Mutually exclusive with Group. + // +optional + Replace *string `json:"replace,omitempty"` +} + +// StringTransformReplace replaces the search string with the replacement string. +type StringTransformReplace struct { + // The Search string to match. + Search string `json:"search"` + + // The Replace string replaces all occurrences of the search string. + Replace string `json:"replace"` +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index de2c8dc..c299f95 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -100,6 +100,13 @@ func (in *EnvironmentSourceSelectorLabelMatcher) DeepCopyInto(out *EnvironmentSo *out = new(string) **out = **in } + if in.Transforms != nil { + in, out := &in.Transforms, &out.Transforms + *out = make([]Transform, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentSourceSelectorLabelMatcher. @@ -172,6 +179,77 @@ func (in *InputSpec) DeepCopy() *InputSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MapTransform) DeepCopyInto(out *MapTransform) { + *out = *in + if in.Pairs != nil { + in, out := &in.Pairs, &out.Pairs + *out = make(map[string]v1.JSON, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MapTransform. +func (in *MapTransform) DeepCopy() *MapTransform { + if in == nil { + return nil + } + out := new(MapTransform) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MatchTransform) DeepCopyInto(out *MatchTransform) { + *out = *in + if in.Patterns != nil { + in, out := &in.Patterns, &out.Patterns + *out = make([]MatchTransformPattern, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.FallbackValue.DeepCopyInto(&out.FallbackValue) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchTransform. +func (in *MatchTransform) DeepCopy() *MatchTransform { + if in == nil { + return nil + } + out := new(MatchTransform) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MatchTransformPattern) DeepCopyInto(out *MatchTransformPattern) { + *out = *in + if in.Literal != nil { + in, out := &in.Literal, &out.Literal + *out = new(string) + **out = **in + } + if in.Regexp != nil { + in, out := &in.Regexp, &out.Regexp + *out = new(string) + **out = **in + } + in.Result.DeepCopyInto(&out.Result) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchTransformPattern. +func (in *MatchTransformPattern) DeepCopy() *MatchTransformPattern { + if in == nil { + return nil + } + out := new(MatchTransformPattern) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PatchPolicy) DeepCopyInto(out *PatchPolicy) { *out = *in @@ -216,3 +294,113 @@ func (in *Policy) DeepCopy() *Policy { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StringTransform) DeepCopyInto(out *StringTransform) { + *out = *in + if in.Format != nil { + in, out := &in.Format, &out.Format + *out = new(string) + **out = **in + } + if in.Convert != nil { + in, out := &in.Convert, &out.Convert + *out = new(StringConversionType) + **out = **in + } + if in.Trim != nil { + in, out := &in.Trim, &out.Trim + *out = new(string) + **out = **in + } + if in.Regexp != nil { + in, out := &in.Regexp, &out.Regexp + *out = new(StringTransformRegexp) + (*in).DeepCopyInto(*out) + } + if in.Replace != nil { + in, out := &in.Replace, &out.Replace + *out = new(StringTransformReplace) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringTransform. +func (in *StringTransform) DeepCopy() *StringTransform { + if in == nil { + return nil + } + out := new(StringTransform) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StringTransformRegexp) DeepCopyInto(out *StringTransformRegexp) { + *out = *in + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(int) + **out = **in + } + if in.Replace != nil { + in, out := &in.Replace, &out.Replace + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringTransformRegexp. +func (in *StringTransformRegexp) DeepCopy() *StringTransformRegexp { + if in == nil { + return nil + } + out := new(StringTransformRegexp) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StringTransformReplace) DeepCopyInto(out *StringTransformReplace) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringTransformReplace. +func (in *StringTransformReplace) DeepCopy() *StringTransformReplace { + if in == nil { + return nil + } + out := new(StringTransformReplace) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Transform) DeepCopyInto(out *Transform) { + *out = *in + if in.Map != nil { + in, out := &in.Map, &out.Map + *out = new(MapTransform) + (*in).DeepCopyInto(*out) + } + if in.Match != nil { + in, out := &in.Match, &out.Match + *out = new(MatchTransform) + (*in).DeepCopyInto(*out) + } + if in.String != nil { + in, out := &in.String, &out.String + *out = new(StringTransform) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Transform. +func (in *Transform) DeepCopy() *Transform { + if in == nil { + return nil + } + out := new(Transform) + in.DeepCopyInto(out) + return out +} diff --git a/package/input/environmentconfigs.fn.crossplane.io_inputs.yaml b/package/input/environmentconfigs.fn.crossplane.io_inputs.yaml index ce2d2b0..c137fd9 100644 --- a/package/input/environmentconfigs.fn.crossplane.io_inputs.yaml +++ b/package/input/environmentconfigs.fn.crossplane.io_inputs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.16.0 name: inputs.environmentconfigs.fn.crossplane.io spec: group: environmentconfigs.fn.crossplane.io @@ -58,13 +58,11 @@ spec: resources are stored in the composite resource at `spec.environmentConfigRefs` and is only updated if it is null. - The list of references is used to compute an in-memory environment at compose time. The data of all object is merged in the order they are listed, meaning the values of EnvironmentConfigs with a larger index take priority over ones with smaller indices. - The computed environment can be accessed in a composition using `FromEnvironmentFieldPath` and `CombineFromEnvironment` patches. items: @@ -109,6 +107,174 @@ spec: key: description: Key of the label to match. type: string + transforms: + description: |- + Transforms is an optional list of transforms to apply to the value + resolved from the composite resource before using it for label matching. + Transforms are applied sequentially. Only applicable when type is + FromCompositeFieldPath. + items: + description: |- + Transform is a unit of process whose input is transformed into an output with + the supplied configuration. + properties: + 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 no 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 + 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. + enum: + - ToUpper + - ToLower + type: string + fmt: + description: |- + Format the input using a Go format string. See + https://golang.org/pkg/fmt/ for details. + type: string + 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 + replace: + description: |- + Replace is the replacement string applied using regexp backreferences. + When set, the transform uses ReplaceAllString with backreference support + (e.g. ${1}, ${2}) instead of extracting a single group. + Mutually exclusive with Group. + 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 + - Replace + type: string + required: + - type + type: object + type: + description: Type of the transform to be run. + enum: + - map + - match + - string + type: string + required: + - type + type: object + type: array type: default: FromCompositeFieldPath description: Type specifies where the value for a diff --git a/transforms.go b/transforms.go new file mode 100644 index 0000000..cd8b737 --- /dev/null +++ b/transforms.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/crossplane-contrib/function-environment-configs/input/v1beta1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/ptr" +) + +const ( + errFmtRequiredField = "%s is required by type %s" + errFmtTransformAtIndex = "transform at index %d returned error" + errFmtTransformTypeFailed = "%s transform could not resolve" + errFmtTransformConfigMissing = "given transform type %s requires configuration" + errFmtTypeNotSupported = "transform type %s is not supported" + + errFmtMapTypeNotSupported = "type %s is not supported for map transform" + errFmtMapNotFound = "key %s is not found in map" + errFmtMapInvalidJSON = "value for key %s is not valid JSON" + + errFmtMatchPattern = "cannot match pattern at index %d" + errFmtMatchParseResult = "cannot parse result of pattern at index %d" + errMatchParseFallbackValue = "cannot parse fallback value" + errMatchFallbackBoth = "cannot set both a fallback value and the fallback to input flag" + errFmtMatchPatternTypeInvalid = "unsupported pattern type '%s'" + errFmtMatchInputTypeInvalid = "unsupported input type '%s'" + errMatchRegexpCompile = "cannot compile regexp" + + errStringTransformTypeFailed = "type %s is not supported for string transform type" + errStringTransformTypeFormat = "string transform of type %s fmt is not set" + errStringTransformTypeConvert = "string transform of type %s convert is not set" + errStringTransformTypeTrim = "string transform of type %s trim is not set" + errStringTransformTypeRegexp = "string transform of type %s regexp is not set" + errStringTransformTypeRegexpFailed = "could not compile regexp" + errStringTransformTypeRegexpNoMatch = "regexp %q had no matches for group %d" + errStringTransformTypeReplace = "string transform of type %s replace is not set" + errStringConvertTypeFailed = "type %s is not supported for string convert" +) + +// ResolveTransforms applies a list of transforms sequentially to an input value. +func ResolveTransforms(transforms []v1beta1.Transform, input any) (any, error) { + val := input + for i, t := range transforms { + var err error + val, err = Resolve(t, val) + if err != nil { + return nil, errors.Wrapf(err, errFmtTransformAtIndex, i) + } + } + return val, nil +} + +// Resolve the supplied Transform. +func Resolve(t v1beta1.Transform, input any) (any, error) { + var out any + var err error + + switch t.Type { + case v1beta1.TransformTypeMap: + if t.Map == nil { + return nil, errors.Errorf(errFmtTransformConfigMissing, t.Type) + } + out, err = ResolveMap(t.Map, input) + case v1beta1.TransformTypeMatch: + if t.Match == nil { + return nil, errors.Errorf(errFmtTransformConfigMissing, t.Type) + } + out, err = ResolveMatch(t.Match, input) + case v1beta1.TransformTypeString: + if t.String == nil { + return nil, errors.Errorf(errFmtTransformConfigMissing, t.Type) + } + out, err = ResolveString(t.String, input) + default: + return nil, errors.Errorf(errFmtTypeNotSupported, string(t.Type)) + } + + return out, errors.Wrapf(err, errFmtTransformTypeFailed, string(t.Type)) +} + +// ResolveMap resolves a Map transform. +func ResolveMap(t *v1beta1.MapTransform, input any) (any, error) { + switch i := input.(type) { + case string: + p, ok := t.Pairs[i] + if !ok { + return nil, errors.Errorf(errFmtMapNotFound, i) + } + var val any + if err := json.Unmarshal(p.Raw, &val); err != nil { + return nil, errors.Wrapf(err, errFmtMapInvalidJSON, i) + } + return val, nil + default: + return nil, errors.Errorf(errFmtMapTypeNotSupported, fmt.Sprintf("%T", input)) + } +} + +// ResolveMatch resolves a Match transform. +func ResolveMatch(t *v1beta1.MatchTransform, input any) (any, error) { + var output any + for i, p := range t.Patterns { + matches, err := Matches(p, input) + if err != nil { + return nil, errors.Wrapf(err, errFmtMatchPattern, i) + } + if matches { + if err := unmarshalJSON(p.Result, &output); err != nil { + return nil, errors.Wrapf(err, errFmtMatchParseResult, i) + } + return output, nil + } + } + + // Fallback to input if no pattern matches and fallback to input is set + if t.FallbackTo == v1beta1.MatchFallbackToTypeInput { + if t.FallbackValue.Size() != 0 { + return nil, errors.New(errMatchFallbackBoth) + } + + return input, nil + } + + // Use fallback value if no pattern matches (or if there are no patterns) + if err := unmarshalJSON(t.FallbackValue, &output); err != nil { + return nil, errors.Wrap(err, errMatchParseFallbackValue) + } + return output, nil +} + +// Matches returns true if the pattern matches the supplied input. +func Matches(p v1beta1.MatchTransformPattern, input any) (bool, error) { + switch p.Type { + case v1beta1.MatchTransformPatternTypeLiteral: + return matchesLiteral(p, input) + case v1beta1.MatchTransformPatternTypeRegexp: + return matchesRegexp(p, input) + } + return false, errors.Errorf(errFmtMatchPatternTypeInvalid, string(p.Type)) +} + +func matchesLiteral(p v1beta1.MatchTransformPattern, input any) (bool, error) { + if p.Literal == nil { + return false, errors.Errorf(errFmtRequiredField, "literal", v1beta1.MatchTransformPatternTypeLiteral) + } + inputStr, ok := input.(string) + if !ok { + return false, errors.Errorf(errFmtMatchInputTypeInvalid, fmt.Sprintf("%T", input)) + } + return inputStr == *p.Literal, nil +} + +func matchesRegexp(p v1beta1.MatchTransformPattern, input any) (bool, error) { + if p.Regexp == nil { + return false, errors.Errorf(errFmtRequiredField, "regexp", v1beta1.MatchTransformPatternTypeRegexp) + } + re, err := regexp.Compile(*p.Regexp) + if err != nil { + return false, errors.Wrap(err, errMatchRegexpCompile) + } + if input == nil { + return false, errors.Errorf(errFmtMatchInputTypeInvalid, "null") + } + inputStr, ok := input.(string) + if !ok { + return false, errors.Errorf(errFmtMatchInputTypeInvalid, fmt.Sprintf("%T", input)) + } + return re.MatchString(inputStr), nil +} + +// unmarshalJSON is a small utility function that returns nil if j contains no +// data. json.Unmarshal seems to not be able to handle this. +func unmarshalJSON(j extv1.JSON, output *any) error { + if len(j.Raw) == 0 { + return nil + } + return json.Unmarshal(j.Raw, output) +} + +// ResolveString resolves a String transform. +func ResolveString(t *v1beta1.StringTransform, input any) (string, error) { //nolint:gocyclo // This is a long but simple switch. + switch t.Type { + case v1beta1.StringTransformTypeFormat: + if t.Format == nil { + return "", errors.Errorf(errStringTransformTypeFormat, string(t.Type)) + } + return fmt.Sprintf(*t.Format, input), nil + case v1beta1.StringTransformTypeConvert: + if t.Convert == nil { + return "", errors.Errorf(errStringTransformTypeConvert, string(t.Type)) + } + return stringConvertTransform(t.Convert, input) + case v1beta1.StringTransformTypeTrimPrefix, v1beta1.StringTransformTypeTrimSuffix: + if t.Trim == nil { + return "", errors.Errorf(errStringTransformTypeTrim, string(t.Type)) + } + return stringTrimTransform(input, t.Type, *t.Trim), nil + case v1beta1.StringTransformTypeRegexp: + if t.Regexp == nil { + return "", errors.Errorf(errStringTransformTypeRegexp, string(t.Type)) + } + return stringRegexpTransform(input, *t.Regexp) + case v1beta1.StringTransformTypeReplace: + if t.Replace == nil { + return "", errors.Errorf(errStringTransformTypeReplace, string(t.Type)) + } + return stringReplaceTransform(input, *t.Replace), nil + default: + return "", errors.Errorf(errStringTransformTypeFailed, string(t.Type)) + } +} + +func stringConvertTransform(t *v1beta1.StringConversionType, input any) (string, error) { + str := fmt.Sprintf("%v", input) + switch *t { + case v1beta1.StringConversionTypeToUpper: + return strings.ToUpper(str), nil + case v1beta1.StringConversionTypeToLower: + return strings.ToLower(str), nil + default: + return "", errors.Errorf(errStringConvertTypeFailed, *t) + } +} + +func stringTrimTransform(input any, t v1beta1.StringTransformType, trim string) string { + str := fmt.Sprintf("%v", input) + if t == v1beta1.StringTransformTypeTrimPrefix { + return strings.TrimPrefix(str, trim) + } + if t == v1beta1.StringTransformTypeTrimSuffix { + return strings.TrimSuffix(str, trim) + } + return str +} + +func stringRegexpTransform(input any, r v1beta1.StringTransformRegexp) (string, error) { + re, err := regexp.Compile(r.Match) + if err != nil { + return "", errors.Wrap(err, errStringTransformTypeRegexpFailed) + } + + str := fmt.Sprintf("%v", input) + + // If Replace is set, use ReplaceAllString with backreference support. + if r.Replace != nil { + return re.ReplaceAllString(str, *r.Replace), nil + } + + groups := re.FindStringSubmatch(str) + + // Return the entire match (group zero) by default. + g := ptr.Deref[int](r.Group, 0) + if len(groups) == 0 || g >= len(groups) { + return "", errors.Errorf(errStringTransformTypeRegexpNoMatch, r.Match, g) + } + + return groups[g], nil +} + +func stringReplaceTransform(input any, r v1beta1.StringTransformReplace) string { + str := fmt.Sprintf("%v", input) + return strings.ReplaceAll(str, r.Search, r.Replace) +} diff --git a/transforms_test.go b/transforms_test.go new file mode 100644 index 0000000..a154306 --- /dev/null +++ b/transforms_test.go @@ -0,0 +1,736 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/crossplane-contrib/function-environment-configs/input/v1beta1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/ptr" +) + +func TestMapResolve(t *testing.T) { + asJSON := func(val any) extv1.JSON { + raw, err := json.Marshal(val) + if err != nil { + t.Fatal(err) + } + res := extv1.JSON{} + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatal(err) + } + return res + } + + type args struct { + t *v1beta1.MapTransform + i any + } + type want struct { + o any + err error + } + + cases := map[string]struct { + args + want + }{ + "NonStringInput": { + args: args{ + t: &v1beta1.MapTransform{}, + i: 5, + }, + want: want{ + err: errors.Errorf(errFmtMapTypeNotSupported, "int"), + }, + }, + "KeyNotFound": { + args: args{ + t: &v1beta1.MapTransform{}, + i: "ola", + }, + want: want{ + err: errors.Errorf(errFmtMapNotFound, "ola"), + }, + }, + "SuccessString": { + args: args{ + t: &v1beta1.MapTransform{Pairs: map[string]extv1.JSON{"ola": asJSON("voila")}}, + i: "ola", + }, + want: want{ + o: "voila", + }, + }, + "SuccessNumber": { + args: args{ + t: &v1beta1.MapTransform{Pairs: map[string]extv1.JSON{"ola": asJSON(1.0)}}, + i: "ola", + }, + want: want{ + o: 1.0, + }, + }, + "SuccessBoolean": { + args: args{ + t: &v1beta1.MapTransform{Pairs: map[string]extv1.JSON{"ola": asJSON(true)}}, + i: "ola", + }, + want: want{ + o: true, + }, + }, + "SuccessObject": { + args: args{ + t: &v1beta1.MapTransform{Pairs: map[string]extv1.JSON{"ola": asJSON(map[string]any{"foo": "bar"})}}, + i: "ola", + }, + want: want{ + o: map[string]any{"foo": "bar"}, + }, + }, + "SuccessSlice": { + args: args{ + t: &v1beta1.MapTransform{Pairs: map[string]extv1.JSON{"ola": asJSON([]string{"foo", "bar"})}}, + i: "ola", + }, + want: want{ + o: []any{"foo", "bar"}, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := ResolveMap(tc.t, tc.i) + + if diff := cmp.Diff(tc.o, got); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + if diff := cmp.Diff(tc.err, err, test.EquateErrors()); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + }) + } +} + +func TestMatchResolve(t *testing.T) { + asJSON := func(val any) extv1.JSON { + raw, err := json.Marshal(val) + if err != nil { + t.Fatal(err) + } + res := extv1.JSON{} + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatal(err) + } + return res + } + + type args struct { + t *v1beta1.MatchTransform + i any + } + type want struct { + o any + err error + } + + cases := map[string]struct { + args + want + }{ + "ErrNonStringInput": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("5"), + }, + }, + }, + i: 5, + }, + want: want{ + err: errors.Wrapf(errors.Errorf(errFmtMatchInputTypeInvalid, "int"), errFmtMatchPattern, 0), + }, + }, + "ErrFallbackValueAndToInput": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackValue: asJSON("foo"), + FallbackTo: "Input", + }, + i: "foo", + }, + want: want{ + err: errors.New(errMatchFallbackBoth), + }, + }, + "NoPatternsFallback": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackValue: asJSON("bar"), + }, + i: "foo", + }, + want: want{ + o: "bar", + }, + }, + "NoPatternsFallbackToValueExplicit": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackValue: asJSON("bar"), + FallbackTo: "Value", // Explicitly set to Value, unnecessary but valid. + }, + i: "foo", + }, + want: want{ + o: "bar", + }, + }, + "NoPatternsFallbackNil": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackValue: asJSON(nil), + }, + i: "foo", + }, + want: want{}, + }, + "NoPatternsFallbackToInput": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackTo: "Input", + }, + i: "foo", + }, + want: want{ + o: "foo", + }, + }, + "NoPatternsFallbackNilToInput": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{}, + FallbackValue: asJSON(nil), + FallbackTo: "Input", + }, + i: "foo", + }, + want: want{ + o: "foo", + }, + }, + "MatchLiteral": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON("bar"), + }, + }, + }, + i: "foo", + }, + want: want{ + o: "bar", + }, + }, + "MatchLiteralFirst": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON("bar"), + }, + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON("not this"), + }, + }, + }, + i: "foo", + }, + want: want{ + o: "bar", + }, + }, + "MatchLiteralWithResultStruct": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON(map[string]any{ + "Hello": "World", + }), + }, + }, + }, + i: "foo", + }, + want: want{ + o: map[string]any{ + "Hello": "World", + }, + }, + }, + "MatchLiteralWithResultSlice": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON([]string{ + "Hello", "World", + }), + }, + }, + }, + i: "foo", + }, + want: want{ + o: []any{ + "Hello", "World", + }, + }, + }, + "MatchLiteralWithResultNumber": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON(5), + }, + }, + }, + i: "foo", + }, + want: want{ + o: 5.0, + }, + }, + "MatchLiteralWithResultBool": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON(true), + }, + }, + }, + i: "foo", + }, + want: want{ + o: true, + }, + }, + "MatchLiteralWithResultNil": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + Literal: ptr.To[string]("foo"), + Result: asJSON(nil), + }, + }, + }, + i: "foo", + }, + want: want{}, + }, + "MatchRegexp": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeRegexp, + Regexp: ptr.To[string]("^foo.*$"), + Result: asJSON("Hello World"), + }, + }, + }, + i: "foobar", + }, + want: want{ + o: "Hello World", + }, + }, + "ErrMissingRegexp": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeRegexp, + }, + }, + }, + }, + want: want{ + err: errors.Wrapf(errors.Errorf(errFmtRequiredField, "regexp", string(v1beta1.MatchTransformPatternTypeRegexp)), errFmtMatchPattern, 0), + }, + }, + "ErrInvalidRegexp": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeRegexp, + Regexp: ptr.To[string]("?="), + }, + }, + }, + }, + want: want{ + // This might break if Go's regexp changes its internal error + // messages: + err: errors.Wrapf(errors.Wrapf(errors.Wrap(errors.Wrap(errors.New("`?`"), "missing argument to repetition operator"), "error parsing regexp"), errMatchRegexpCompile), errFmtMatchPattern, 0), + }, + }, + "ErrMissingLiteral": { + args: args{ + t: &v1beta1.MatchTransform{ + Patterns: []v1beta1.MatchTransformPattern{ + { + Type: v1beta1.MatchTransformPatternTypeLiteral, + }, + }, + }, + }, + want: want{ + err: errors.Wrapf(errors.Errorf(errFmtRequiredField, "literal", string(v1beta1.MatchTransformPatternTypeLiteral)), errFmtMatchPattern, 0), + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := ResolveMatch(tc.t, tc.i) + + if diff := cmp.Diff(tc.o, got); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + if diff := cmp.Diff(tc.err, err, test.EquateErrors()); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + }) + } +} + +func TestStringResolve(t *testing.T) { + type args struct { + stype v1beta1.StringTransformType + fmts *string + convert *v1beta1.StringConversionType + trim *string + regexp *v1beta1.StringTransformRegexp + replace *v1beta1.StringTransformReplace + i any + } + type want struct { + o string + err error + } + sFmt := "verycool%s" + iFmt := "the largest %d" + + upper := v1beta1.StringConversionTypeToUpper + lower := v1beta1.StringConversionTypeToLower + wrongConvertType := v1beta1.StringConversionType("Something") + + prefix := "https://" + suffix := "-test" + + cases := map[string]struct { + args + want + }{ + "NotSupportedType": { + args: args{ + stype: "Something", + i: "value", + }, + want: want{ + err: errors.Errorf(errStringTransformTypeFailed, "Something"), + }, + }, + "FmtFailed": { + args: args{ + stype: v1beta1.StringTransformTypeFormat, + i: "value", + }, + want: want{ + err: errors.Errorf(errStringTransformTypeFormat, string(v1beta1.StringTransformTypeFormat)), + }, + }, + "FmtString": { + args: args{ + stype: v1beta1.StringTransformTypeFormat, + fmts: &sFmt, + i: "thing", + }, + want: want{ + o: "verycoolthing", + }, + }, + "FmtInteger": { + args: args{ + stype: v1beta1.StringTransformTypeFormat, + fmts: &iFmt, + i: 8, + }, + want: want{ + o: "the largest 8", + }, + }, + "ConvertNotSet": { + args: args{ + stype: v1beta1.StringTransformTypeConvert, + i: "crossplane", + }, + want: want{ + err: errors.Errorf(errStringTransformTypeConvert, string(v1beta1.StringTransformTypeConvert)), + }, + }, + "ConvertTypFailed": { + args: args{ + stype: v1beta1.StringTransformTypeConvert, + convert: &wrongConvertType, + i: "crossplane", + }, + want: want{ + err: errors.Errorf(errStringConvertTypeFailed, wrongConvertType), + }, + }, + "ConvertToUpper": { + args: args{ + stype: v1beta1.StringTransformTypeConvert, + convert: &upper, + i: "crossplane", + }, + want: want{ + o: "CROSSPLANE", + }, + }, + "ConvertToLower": { + args: args{ + stype: v1beta1.StringTransformTypeConvert, + convert: &lower, + i: "CrossPlane", + }, + want: want{ + o: "crossplane", + }, + }, + "TrimPrefix": { + args: args{ + stype: v1beta1.StringTransformTypeTrimPrefix, + trim: &prefix, + i: "https://crossplane.io", + }, + want: want{ + o: "crossplane.io", + }, + }, + "TrimSuffix": { + args: args{ + stype: v1beta1.StringTransformTypeTrimSuffix, + trim: &suffix, + i: "my-string-test", + }, + want: want{ + o: "my-string", + }, + }, + "TrimPrefixWithoutMatch": { + args: args{ + stype: v1beta1.StringTransformTypeTrimPrefix, + trim: &prefix, + i: "crossplane.io", + }, + want: want{ + o: "crossplane.io", + }, + }, + "TrimSuffixWithoutMatch": { + args: args{ + stype: v1beta1.StringTransformTypeTrimSuffix, + trim: &suffix, + i: "my-string", + }, + want: want{ + o: "my-string", + }, + }, + "RegexpNotCompiling": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "[a-z", + }, + i: "my-string", + }, + want: want{ + err: errors.Wrap(errors.New("error parsing regexp: missing closing ]: `[a-z`"), errStringTransformTypeRegexpFailed), + }, + }, + "RegexpSimpleMatch": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "[0-9]", + }, + i: "my-1-string", + }, + want: want{ + o: "1", + }, + }, + "RegexpCaptureGroup": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "my-([0-9]+)-string", + Group: ptr.To[int](1), + }, + i: "my-1-string", + }, + want: want{ + o: "1", + }, + }, + "RegexpReplaceWithBackreferences": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "^team-(.+)-(.+)$", + Replace: ptr.To[string]("${1}-${2}-environment-config"), + }, + i: "team-alpha-prod", + }, + want: want{ + o: "alpha-prod-environment-config", + }, + }, + "RegexpReplaceSwapGroups": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "^team-(.+)-(.+)$", + Replace: ptr.To[string]("${2}-${1}-environment-config"), + }, + i: "team-alpha-prod", + }, + want: want{ + o: "prod-alpha-environment-config", + }, + }, + "RegexpReplaceNoMatch": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "^team-(.+)-(.+)$", + Replace: ptr.To[string]("${1}-environment-config"), + }, + i: "no-match-here", + }, + want: want{ + o: "no-match-here", + }, + }, + "RegexpNoSuchCaptureGroup": { + args: args{ + stype: v1beta1.StringTransformTypeRegexp, + regexp: &v1beta1.StringTransformRegexp{ + Match: "my-([0-9]+)-string", + Group: ptr.To[int](2), + }, + i: "my-1-string", + }, + want: want{ + err: errors.Errorf(errStringTransformTypeRegexpNoMatch, "my-([0-9]+)-string", 2), + }, + }, + "ReplaceFound": { + args: args{ + stype: v1beta1.StringTransformTypeReplace, + replace: &v1beta1.StringTransformReplace{ + Search: "Cr", + Replace: "B", + }, + i: "Crossplane", + }, + want: want{ + o: "Bossplane", + }, + }, + "ReplaceNotFound": { + args: args{ + stype: v1beta1.StringTransformTypeReplace, + replace: &v1beta1.StringTransformReplace{ + Search: "xx", + Replace: "zz", + }, + i: "Crossplane", + }, + want: want{ + o: "Crossplane", + }, + }, + "ReplaceRemove": { + args: args{ + stype: v1beta1.StringTransformTypeReplace, + replace: &v1beta1.StringTransformReplace{ + Search: "ss", + Replace: "", + }, + i: "Crossplane", + }, + want: want{ + o: "Croplane", + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tr := &v1beta1.StringTransform{ + Type: tc.stype, + Format: tc.fmts, + Convert: tc.convert, + Trim: tc.trim, + Regexp: tc.regexp, + Replace: tc.replace, + } + + got, err := ResolveString(tr, tc.i) + + if diff := cmp.Diff(tc.o, got); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + if diff := cmp.Diff(tc.err, err, test.EquateErrors()); diff != "" { + t.Errorf("Resolve(b): -want, +got:\n%s", diff) + } + }) + } +}