diff --git a/apis/common/common.go b/apis/common/common.go new file mode 100644 index 0000000..a5be938 --- /dev/null +++ b/apis/common/common.go @@ -0,0 +1,16 @@ +/* +Copyright 2023 The Crossplane Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package common contains shared types that are used in multiple CRDs. +// +kubebuilder:object:generate=true +package common diff --git a/apis/common/secrets_injections.go b/apis/common/secrets_injections.go new file mode 100644 index 0000000..3a9e5b6 --- /dev/null +++ b/apis/common/secrets_injections.go @@ -0,0 +1,51 @@ +package common + +// SecretRef contains the name and namespace of a Kubernetes secret. +type SecretRef struct { + // Name is the name of the Kubernetes secret. + Name string `json:"name"` + + // Namespace is the namespace of the Kubernetes secret. + Namespace string `json:"namespace"` +} + +// SecretInjectionConfig represents the configuration for injecting secret data into a Kubernetes secret. +type SecretInjectionConfig struct { + // SecretRef contains the name and namespace of the Kubernetes secret where the data will be injected. + SecretRef SecretRef `json:"secretRef"` + + // SecretKey is the key within the Kubernetes secret where the data will be injected. + // Deprecated: Use KeyMappings for injecting single or multiple keys. + SecretKey string `json:"secretKey,omitempty"` + + // ResponsePath is a jq filter expression representing the path in the response where the secret value will be extracted from. + // Deprecated: Use KeyMappings for injecting single or multiple keys. + ResponsePath string `json:"responsePath,omitempty"` + + // KeyMappings allows injecting data into single or multiple keys within the same Kubernetes secret. + KeyMappings []KeyInjection `json:"keyMappings,omitempty"` + + // Metadata contains labels and annotations to apply to the Kubernetes secret. + Metadata Metadata `json:"metadata,omitempty"` + + // SetOwnerReference determines whether to set the owner reference on the Kubernetes secret. + SetOwnerReference bool `json:"setOwnerReference,omitempty"` +} + +// KeyInjection represents the configuration for injecting data into a specific key in a Kubernetes secret. +type KeyInjection struct { + // SecretKey is the key within the Kubernetes secret where the data will be injected. + SecretKey string `json:"secretKey"` + + // ResponseJQ is a jq filter expression representing the path in the response where the secret value will be extracted from. + ResponseJQ string `json:"responseJQ"` +} + +// Metadata contains labels and annotations to apply to a Kubernetes secret. +type Metadata struct { + // Labels contains key-value pairs to apply as labels to the Kubernetes secret. + Labels map[string]string `json:"labels,omitempty"` + + // Annotations contains key-value pairs to apply as annotations to the Kubernetes secret. + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/apis/common/zz_generated.deepcopy.go b/apis/common/zz_generated.deepcopy.go new file mode 100644 index 0000000..b154bd1 --- /dev/null +++ b/apis/common/zz_generated.deepcopy.go @@ -0,0 +1,104 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2020 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package common + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeyInjection) DeepCopyInto(out *KeyInjection) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeyInjection. +func (in *KeyInjection) DeepCopy() *KeyInjection { + if in == nil { + return nil + } + out := new(KeyInjection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Metadata) DeepCopyInto(out *Metadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Metadata. +func (in *Metadata) DeepCopy() *Metadata { + if in == nil { + return nil + } + out := new(Metadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretInjectionConfig) DeepCopyInto(out *SecretInjectionConfig) { + *out = *in + out.SecretRef = in.SecretRef + if in.KeyMappings != nil { + in, out := &in.KeyMappings, &out.KeyMappings + *out = make([]KeyInjection, len(*in)) + copy(*out, *in) + } + in.Metadata.DeepCopyInto(&out.Metadata) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretInjectionConfig. +func (in *SecretInjectionConfig) DeepCopy() *SecretInjectionConfig { + if in == nil { + return nil + } + out := new(SecretInjectionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretRef) DeepCopyInto(out *SecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. +func (in *SecretRef) DeepCopy() *SecretRef { + if in == nil { + return nil + } + out := new(SecretRef) + in.DeepCopyInto(out) + return out +} diff --git a/apis/disposablerequest/v1alpha2/disposablerequest_types.go b/apis/disposablerequest/v1alpha2/disposablerequest_types.go index ab6c5eb..aabbe32 100644 --- a/apis/disposablerequest/v1alpha2/disposablerequest_types.go +++ b/apis/disposablerequest/v1alpha2/disposablerequest_types.go @@ -22,6 +22,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/crossplane-contrib/provider-http/apis/common" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" ) @@ -57,7 +58,7 @@ type DisposableRequestParameters struct { ShouldLoopInfinitely bool `json:"shouldLoopInfinitely,omitempty"` // SecretInjectionConfig specifies the secrets receiving patches from response data. - SecretInjectionConfigs []SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"` + SecretInjectionConfigs []common.SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"` } // A DisposableRequestSpec defines the desired state of a DisposableRequest. @@ -66,30 +67,6 @@ type DisposableRequestSpec struct { ForProvider DisposableRequestParameters `json:"forProvider"` } -// SecretInjectionConfig represents the configuration for injecting secret data into a Kubernetes secret. -type SecretInjectionConfig struct { - // SecretRef contains the name and namespace of the Kubernetes secret where the data will be injected. - SecretRef SecretRef `json:"secretRef"` - - // SecretKey is the key within the Kubernetes secret where the data will be injected. - SecretKey string `json:"secretKey"` - - // ResponsePath is is a jq filter expression represents the path in the response where the secret value will be extracted from. - ResponsePath string `json:"responsePath"` - - // SetOwnerReference determines whether to set the owner reference on the Kubernetes secret. - SetOwnerReference bool `json:"setOwnerReference,omitempty"` -} - -// SecretRef contains the name and namespace of a Kubernetes secret. -type SecretRef struct { - // Name is the name of the Kubernetes secret. - Name string `json:"name"` - - // Namespace is the namespace of the Kubernetes secret. - Namespace string `json:"namespace"` -} - type Response struct { StatusCode int `json:"statusCode,omitempty"` Body string `json:"body,omitempty"` diff --git a/apis/disposablerequest/v1alpha2/zz_generated.deepcopy.go b/apis/disposablerequest/v1alpha2/zz_generated.deepcopy.go index 7a363eb..d5f1e0a 100644 --- a/apis/disposablerequest/v1alpha2/zz_generated.deepcopy.go +++ b/apis/disposablerequest/v1alpha2/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha2 import ( + "github.com/crossplane-contrib/provider-http/apis/common" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -120,8 +121,10 @@ func (in *DisposableRequestParameters) DeepCopyInto(out *DisposableRequestParame } if in.SecretInjectionConfigs != nil { in, out := &in.SecretInjectionConfigs, &out.SecretInjectionConfigs - *out = make([]SecretInjectionConfig, len(*in)) - copy(*out, *in) + *out = make([]common.SecretInjectionConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -232,34 +235,3 @@ func (in *Response) DeepCopy() *Response { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretInjectionConfig) DeepCopyInto(out *SecretInjectionConfig) { - *out = *in - out.SecretRef = in.SecretRef -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretInjectionConfig. -func (in *SecretInjectionConfig) DeepCopy() *SecretInjectionConfig { - if in == nil { - return nil - } - out := new(SecretInjectionConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretRef) DeepCopyInto(out *SecretRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. -func (in *SecretRef) DeepCopy() *SecretRef { - if in == nil { - return nil - } - out := new(SecretRef) - in.DeepCopyInto(out) - return out -} diff --git a/apis/request/v1alpha2/request_types.go b/apis/request/v1alpha2/request_types.go index df878f1..591d248 100644 --- a/apis/request/v1alpha2/request_types.go +++ b/apis/request/v1alpha2/request_types.go @@ -22,6 +22,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/crossplane-contrib/provider-http/apis/common" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" ) @@ -55,7 +56,7 @@ type RequestParameters struct { InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` // SecretInjectionConfig specifies the secrets receiving patches for response data. - SecretInjectionConfigs []SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"` + SecretInjectionConfigs []common.SecretInjectionConfig `json:"secretInjectionConfigs,omitempty"` // ExpectedResponseCheck specifies the mechanism to validate the OBSERVE response against expected value. ExpectedResponseCheck ExpectedResponseCheck `json:"expectedResponseCheck,omitempty"` @@ -107,30 +108,6 @@ type RequestSpec struct { ForProvider RequestParameters `json:"forProvider"` } -// SecretInjectionConfig represents the configuration for injecting secret data into a Kubernetes secret. -type SecretInjectionConfig struct { - // SecretRef contains the name and namespace of the Kubernetes secret where the data will be injected. - SecretRef SecretRef `json:"secretRef"` - - // SecretKey is the key within the Kubernetes secret where the data will be injected. - SecretKey string `json:"secretKey"` - - // ResponsePath is is a jq filter expression represents the path in the response where the secret value will be extracted from. - ResponsePath string `json:"responsePath"` - - // SetOwnerReference determines whether to set the owner reference on the Kubernetes secret. - SetOwnerReference bool `json:"setOwnerReference,omitempty"` -} - -// SecretRef contains the name and namespace of a Kubernetes secret. -type SecretRef struct { - // Name is the name of the Kubernetes secret. - Name string `json:"name"` - - // Namespace is the namespace of the Kubernetes secret. - Namespace string `json:"namespace"` -} - // RequestObservation are the observable fields of a Request. type Response struct { StatusCode int `json:"statusCode,omitempty"` diff --git a/apis/request/v1alpha2/zz_generated.deepcopy.go b/apis/request/v1alpha2/zz_generated.deepcopy.go index cf28303..54c2e9a 100644 --- a/apis/request/v1alpha2/zz_generated.deepcopy.go +++ b/apis/request/v1alpha2/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha2 import ( + "github.com/crossplane-contrib/provider-http/apis/common" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -195,8 +196,10 @@ func (in *RequestParameters) DeepCopyInto(out *RequestParameters) { } if in.SecretInjectionConfigs != nil { in, out := &in.SecretInjectionConfigs, &out.SecretInjectionConfigs - *out = make([]SecretInjectionConfig, len(*in)) - copy(*out, *in) + *out = make([]common.SecretInjectionConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.ExpectedResponseCheck = in.ExpectedResponseCheck out.IsRemovedCheck = in.IsRemovedCheck @@ -278,34 +281,3 @@ func (in *Response) DeepCopy() *Response { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretInjectionConfig) DeepCopyInto(out *SecretInjectionConfig) { - *out = *in - out.SecretRef = in.SecretRef -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretInjectionConfig. -func (in *SecretInjectionConfig) DeepCopy() *SecretInjectionConfig { - if in == nil { - return nil - } - out := new(SecretInjectionConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretRef) DeepCopyInto(out *SecretRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. -func (in *SecretRef) DeepCopy() *SecretRef { - if in == nil { - return nil - } - out := new(SecretRef) - in.DeepCopyInto(out) - return out -} diff --git a/examples/sample/disposablerequest.yaml b/examples/sample/disposablerequest.yaml index 9d59845..aebd761 100644 --- a/examples/sample/disposablerequest.yaml +++ b/examples/sample/disposablerequest.yaml @@ -37,17 +37,19 @@ spec: - secretRef: name: notification-response namespace: default - secretKey: notification-status - responsePath: .body.status + metadata: + labels: + status: .body.status + annotations: + key: value + keyMappings: + - secretKey: notification-status + responseJQ: .body.status + - secretKey: notification-id + responseJQ: .body.id # setOwnerReference determines if the secret should be deleted when the associated resource is deleted. - # When injecting multiple keys into the same secret, ensure this field is set consistently for all keys. - setOwnerReference: true - - secretRef: - name: notification-response - namespace: default - secretKey: notification-id - responsePath: .body.id setOwnerReference: true + providerConfigRef: name: http-conf # TODO: check if it's possible to modify the deletionPolicy to be orphan by default. diff --git a/examples/sample/request.yaml b/examples/sample/request.yaml index 8668f2c..82d4cfc 100644 --- a/examples/sample/request.yaml +++ b/examples/sample/request.yaml @@ -96,21 +96,27 @@ spec: - secretRef: name: response-secret namespace: default - secretKey: extracted-user-email - responsePath: .body.email + metadata: + labels: + managed-by: provider-http + annotations: + username: .body.username + keyMappings: + - secretKey: extracted-user-email + responseJQ: .body.email + - secretKey: extracted-header-data + responseJQ: .headers."X-Secret-Header"[0] # setOwnerReference determines if the secret should be deleted when the associated resource is deleted. - # When injecting multiple keys into the same secret, ensure this field is set consistently for all keys. - setOwnerReference: true - - secretRef: - name: response-secret - namespace: default - secretKey: extracted-header-data - responsePath: .headers."X-Secret-Header"[0] setOwnerReference: true + - secretRef: name: response-user-password namespace: default - secretKey: extracted-user-password - responsePath: .body.password + keyMappings: + - secretKey: extracted-user-password + responseJQ: .body.password + - secretKey: extracted-user-age + responseJQ: .body.age + providerConfigRef: name: http-conf diff --git a/internal/controller/disposablerequest/disposablerequest.go b/internal/controller/disposablerequest/disposablerequest.go index c885200..5b92457 100644 --- a/internal/controller/disposablerequest/disposablerequest.go +++ b/internal/controller/disposablerequest/disposablerequest.go @@ -42,7 +42,6 @@ import ( apisv1alpha1 "github.com/crossplane-contrib/provider-http/apis/v1alpha1" httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" "github.com/crossplane-contrib/provider-http/internal/utils" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -214,7 +213,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ if err != nil { setErr := resource.SetError(err) - c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) + datapatcher.ApplyResponseDataToSecrets(ctx, c.localKube, c.logger, &resource.HttpResponse, cr.Spec.ForProvider.SecretInjectionConfigs, cr) if settingError := utils.SetRequestResourceStatus(*resource, setErr, resource.SetLastReconcileTime(), resource.SetRequestDetails()); settingError != nil { return errors.Wrap(settingError, utils.ErrFailedToSetStatus) } @@ -222,7 +221,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ } if utils.IsHTTPError(resource.HttpResponse.StatusCode) { - c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) + datapatcher.ApplyResponseDataToSecrets(ctx, c.localKube, c.logger, &resource.HttpResponse, cr.Spec.ForProvider.SecretInjectionConfigs, cr) if settingError := utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(), resource.SetRequestDetails(), resource.SetError(nil)); settingError != nil { return errors.Wrap(settingError, utils.ErrFailedToSetStatus) } @@ -235,7 +234,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.DisposableRequ return err } - c.patchResponseToSecret(ctx, cr, &resource.HttpResponse) + datapatcher.ApplyResponseDataToSecrets(ctx, c.localKube, c.logger, &resource.HttpResponse, cr.Spec.ForProvider.SecretInjectionConfigs, cr) if !isExpectedResponse { limit := utils.GetRollbackRetriesLimit(cr.Spec.ForProvider.RollbackRetriesLimit) return utils.SetRequestResourceStatus(*resource, resource.SetStatusCode(), resource.SetLastReconcileTime(), resource.SetHeaders(), resource.SetBody(), @@ -300,21 +299,6 @@ func (c *external) Delete(_ context.Context, _ resource.Managed) error { return nil } -func (c *external) patchResponseToSecret(ctx context.Context, cr *v1alpha2.DisposableRequest, response *httpClient.HttpResponse) { - for _, ref := range cr.Spec.ForProvider.SecretInjectionConfigs { - var owner metav1.Object = nil - - if ref.SetOwnerReference { - owner = cr - } - - err := datapatcher.PatchResponseToSecret(ctx, c.localKube, c.logger, response, ref.ResponsePath, ref.SecretKey, ref.SecretRef.Name, ref.SecretRef.Namespace, owner) - if err != nil { - c.logger.Info(fmt.Sprintf(errPatchDataToSecret, ref.SecretRef.Name, ref.SecretRef.Namespace, ref.SecretKey, err.Error())) - } - } -} - // WithCustomPollIntervalHook returns a managed.ReconcilerOption that sets a custom poll interval based on the DisposableRequest spec. func WithCustomPollIntervalHook() managed.ReconcilerOption { return managed.WithPollIntervalHook(func(mg resource.Managed, pollInterval time.Duration) time.Duration { diff --git a/internal/controller/request/observe.go b/internal/controller/request/observe.go index 9504b51..c1bf30c 100644 --- a/internal/controller/request/observe.go +++ b/internal/controller/request/observe.go @@ -9,6 +9,7 @@ import ( "github.com/crossplane-contrib/provider-http/internal/controller/request/observe" "github.com/crossplane-contrib/provider-http/internal/controller/request/requestgen" "github.com/crossplane-contrib/provider-http/internal/controller/request/requestmapping" + datapatcher "github.com/crossplane-contrib/provider-http/internal/data-patcher" "github.com/crossplane-contrib/provider-http/internal/utils" "github.com/pkg/errors" ) @@ -64,7 +65,7 @@ func (c *external) isUpToDate(ctx context.Context, cr *v1alpha2.Request) (Observ return FailedObserve(), err } - c.patchResponseToSecret(ctx, cr, &details.HttpResponse) + datapatcher.ApplyResponseDataToSecrets(ctx, c.localKube, c.logger, &details.HttpResponse, cr.Spec.ForProvider.SecretInjectionConfigs, cr) return c.determineIfUpToDate(ctx, cr, details, responseErr) } diff --git a/internal/controller/request/request.go b/internal/controller/request/request.go index 9a24704..f6570b4 100644 --- a/internal/controller/request/request.go +++ b/internal/controller/request/request.go @@ -18,7 +18,6 @@ package request import ( "context" - "fmt" "time" "github.com/crossplane/crossplane-runtime/pkg/logging" @@ -43,7 +42,6 @@ import ( "github.com/crossplane-contrib/provider-http/internal/controller/request/statushandler" datapatcher "github.com/crossplane-contrib/provider-http/internal/data-patcher" "github.com/crossplane-contrib/provider-http/internal/utils" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -204,7 +202,7 @@ func (c *external) deployAction(ctx context.Context, cr *v1alpha2.Request, actio } details, err := c.http.SendRequest(ctx, mapping.Method, requestDetails.Url, requestDetails.Body, requestDetails.Headers, cr.Spec.ForProvider.InsecureSkipTLSVerify) - c.patchResponseToSecret(ctx, cr, &details.HttpResponse) + datapatcher.ApplyResponseDataToSecrets(ctx, c.localKube, c.logger, &details.HttpResponse, cr.Spec.ForProvider.SecretInjectionConfigs, cr) statusHandler, err := statushandler.NewStatusHandler(ctx, cr, details, err, c.localKube, c.logger) if err != nil { @@ -240,19 +238,3 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error { return errors.Wrap(c.deployAction(ctx, cr, v1alpha2.ActionRemove), errFailedToSendHttpRequest) } - -// patchResponseToSecret patches the response data to the secret based on the given Request resource and Mapping configuration. -func (c *external) patchResponseToSecret(ctx context.Context, cr *v1alpha2.Request, response *httpClient.HttpResponse) { - for _, ref := range cr.Spec.ForProvider.SecretInjectionConfigs { - var owner metav1.Object = nil - - if ref.SetOwnerReference { - owner = cr - } - - err := datapatcher.PatchResponseToSecret(ctx, c.localKube, c.logger, response, ref.ResponsePath, ref.SecretKey, ref.SecretRef.Name, ref.SecretRef.Namespace, owner) - if err != nil { - c.logger.Info(fmt.Sprintf(errPatchDataToSecret, ref.SecretRef.Name, ref.SecretRef.Namespace, ref.SecretKey, err.Error())) - } - } -} diff --git a/internal/data-patcher/parser.go b/internal/data-patcher/parser.go index a6f79a0..b8d63dc 100644 --- a/internal/data-patcher/parser.go +++ b/internal/data-patcher/parser.go @@ -5,19 +5,13 @@ import ( "regexp" "strings" - "strconv" - "context" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" - httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" - "github.com/crossplane-contrib/provider-http/internal/jq" - json_util "github.com/crossplane-contrib/provider-http/internal/json" kubehandler "github.com/crossplane-contrib/provider-http/internal/kube-handler" "github.com/crossplane/crossplane-runtime/pkg/logging" - "github.com/pkg/errors" ) const ( @@ -91,49 +85,6 @@ func patchSecretsToValue(ctx context.Context, localKube client.Client, valueToHa } -// patchValueToSecret patches a value to a secret. -func patchValueToSecret(ctx context.Context, kubeClient client.Client, logger logging.Logger, data *httpClient.HttpResponse, secret *corev1.Secret, secretKey string, requestFieldPath string) error { - dataMap, err := json_util.StructToMap(data) - if err != nil { - return errors.Wrap(err, errConvertData) - } - - json_util.ConvertJSONStringsToMaps(&dataMap) - - valueToPatch, err := jq.ParseString(requestFieldPath, dataMap) - if err != nil { - boolResult, err := jq.ParseBool(requestFieldPath, dataMap) - if err != nil { - valueToPatch = "" - } else { - valueToPatch = strconv.FormatBool(boolResult) - } - } - - if valueToPatch == "" { - logger.Info(fmt.Sprintf(errEmptyKey, requestFieldPath, fmt.Sprint(data))) - return nil - } - - if secret.Data == nil { - secret.Data = make(map[string][]byte) - } - - secret.Data[secretKey] = []byte(valueToPatch) - - // patch the {{name:namespace:key}} of secret instead of the sensitive value - placeholder := fmt.Sprintf("{{%s:%s:%s}}", secret.Name, secret.Namespace, secretKey) - data.Body = strings.ReplaceAll(data.Body, valueToPatch, placeholder) - for _, headersList := range data.Headers { - for i, header := range headersList { - newHeader := strings.ReplaceAll(header, valueToPatch, placeholder) - headersList[i] = newHeader - } - } - - return kubehandler.UpdateSecret(ctx, kubeClient, secret) -} - // patchSecretsInMap traverses a map and patches secrets into any string values. func patchSecretsInMap(ctx context.Context, localKube client.Client, data map[string]interface{}, logger logging.Logger) error { for key, value := range data { diff --git a/internal/data-patcher/patch.go b/internal/data-patcher/patch.go index dd2f9cf..60a939d 100644 --- a/internal/data-patcher/patch.go +++ b/internal/data-patcher/patch.go @@ -2,17 +2,21 @@ package datapatcher import ( "context" + "fmt" + "github.com/crossplane-contrib/provider-http/apis/common" httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" kubehandler "github.com/crossplane-contrib/provider-http/internal/kube-handler" "github.com/crossplane/crossplane-runtime/pkg/logging" "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) const ( errPatchToReferencedSecret = "cannot patch to referenced secret" + errPatchDataToSecret = "Warning, couldn't patch data from request to secret %s:%s, error: %s" ) // PatchSecretsIntoString patches secrets into the provided string. @@ -51,14 +55,40 @@ func copyHeaders(headers map[string][]string) map[string][]string { return headersCopy } -// PatchResponseToSecret patches response data into a Kubernetes secret. -func PatchResponseToSecret(ctx context.Context, localKube client.Client, logger logging.Logger, data *httpClient.HttpResponse, path, secretKey, secretName, secretNamespace string, owner metav1.Object) error { - secret, err := kubehandler.GetOrCreateSecret(ctx, localKube, secretName, secretNamespace, owner) +// patchResponseDataToSecret patches response data into a Kubernetes secret. +func patchResponseDataToSecret(ctx context.Context, localKube client.Client, logger logging.Logger, data *httpClient.HttpResponse, owner metav1.Object, secretConfig common.SecretInjectionConfig) error { + secret, err := kubehandler.GetOrCreateSecret(ctx, localKube, secretConfig.SecretRef.Name, secretConfig.SecretRef.Namespace, owner) if err != nil { return err } - err = patchValueToSecret(ctx, localKube, logger, data, secret, secretKey, path) + err = applySecretConfig(ctx, localKube, logger, data, secretConfig, secret) + if err != nil { + return err + } + + return nil +} + +// applySecretConfig applies the secret configuration to the secret. +func applySecretConfig(ctx context.Context, localKube client.Client, logger logging.Logger, data *httpClient.HttpResponse, secretConfig common.SecretInjectionConfig, secret *v1.Secret) error { + var err error + + if secretConfig.KeyMappings != nil { + for _, mapping := range secretConfig.KeyMappings { + err = updateSecretWithPatchedValue(ctx, localKube, logger, data, secret, mapping.SecretKey, mapping.ResponseJQ) + if err != nil { + return errors.Wrap(err, errPatchToReferencedSecret) + } + } + } else { + err = updateSecretWithPatchedValue(ctx, localKube, logger, data, secret, secretConfig.SecretKey, secretConfig.ResponsePath) + if err != nil { + return errors.Wrap(err, errPatchToReferencedSecret) + } + } + + err = updateSecretLabelsAndAnnotations(ctx, localKube, logger, data, secret, secretConfig.Metadata.Labels, secretConfig.Metadata.Annotations) if err != nil { return errors.Wrap(err, errPatchToReferencedSecret) } @@ -66,6 +96,24 @@ func PatchResponseToSecret(ctx context.Context, localKube client.Client, logger return nil } +// ApplyResponseDataToSecrets applies response data to Kubernetes Secrets as specified in the resource's SecretInjectionConfigs. +// For each SecretInjectionConfig, it extracts a value from the HTTP response and patches it into the referenced Secret. +// Ownership of the Secret is optionally set based on the configuration. +func ApplyResponseDataToSecrets(ctx context.Context, localKube client.Client, logger logging.Logger, response *httpClient.HttpResponse, secretConfigs []common.SecretInjectionConfig, cr metav1.Object) { + for _, ref := range secretConfigs { + var owner metav1.Object = nil + + if ref.SetOwnerReference { + owner = cr + } + + err := patchResponseDataToSecret(ctx, localKube, logger, response, owner, ref) + if err != nil { + logger.Info(fmt.Sprintf(errPatchDataToSecret, ref.SecretRef.Name, ref.SecretRef.Namespace, err.Error())) + } + } +} + // PatchSecretsIntoMap takes a map of string to interface{} and patches secrets // into any string values within the map, including nested maps and slices. func PatchSecretsIntoMap(ctx context.Context, localKube client.Client, data map[string]interface{}, logger logging.Logger) (map[string]interface{}, error) { diff --git a/internal/data-patcher/secret_patcher.go b/internal/data-patcher/secret_patcher.go new file mode 100644 index 0000000..df648b8 --- /dev/null +++ b/internal/data-patcher/secret_patcher.go @@ -0,0 +1,187 @@ +package datapatcher + +import ( + "context" + "fmt" + "strconv" + "strings" + + httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" + "github.com/crossplane-contrib/provider-http/internal/jq" + json_util "github.com/crossplane-contrib/provider-http/internal/json" + kubehandler "github.com/crossplane-contrib/provider-http/internal/kube-handler" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + logUpdateSecretLabelsAndAnnotations = "Updating labels and annotations for Secret [%s/%s]" + logNoUpdatesRequired = "No updates required for labels and annotations of Secret [%s/%s]" +) + +// updateSecretLabelsAndAnnotations updates the labels and annotations of a Kubernetes Secret +// based on the provided maps. It ensures the Secret is only updated if there are actual changes. +func updateSecretLabelsAndAnnotations(ctx context.Context, kubeClient client.Client, logger logging.Logger, data *httpClient.HttpResponse, secret *corev1.Secret, labels map[string]string, annotations map[string]string) error { + updated := false + + dataMap, err := prepareDataMap(data) + if err != nil { + return err + } + + // Update labels + if secret.Labels == nil && labels != nil { + secret.Labels = make(map[string]string) + } + updated = syncMap(logger, &secret.Labels, labels, dataMap) || updated + + // Update annotations + if secret.Annotations == nil && annotations != nil { + secret.Annotations = make(map[string]string) + } + updated = syncMap(logger, &secret.Annotations, annotations, dataMap) || updated + + // Update the Secret only if changes were made + if updated { + logger.Debug(fmt.Sprintf(logUpdateSecretLabelsAndAnnotations, secret.Namespace, secret.Name)) + return kubehandler.UpdateSecret(ctx, kubeClient, secret) + } + + logger.Debug(fmt.Sprintf(logNoUpdatesRequired, secret.Namespace, secret.Name)) + return nil +} + +// updateSecretWithPatchedValue extracts a specified value from an HTTP response, +// transforms it if necessary, and patches it into a Kubernetes Secret. Additionally, +// it replaces the sensitive value in the HTTP response body and headers with a placeholder. +func updateSecretWithPatchedValue(ctx context.Context, kubeClient client.Client, logger logging.Logger, data *httpClient.HttpResponse, secret *corev1.Secret, secretKey string, requestFieldPath string) error { + // Step 1: Parse and prepare data + dataMap, err := prepareDataMap(data) + if err != nil { + return err + } + + // Step 2: Extract the value to patch + valueToPatch, err := extractValueToPatch(dataMap, requestFieldPath) + if err != nil { + logger.Info(fmt.Sprintf(errEmptyKey, requestFieldPath, fmt.Sprint(data))) + return nil + } + + // Step 3: Check if the value is already present + if isSecretDataUpToDate(secret, secretKey, valueToPatch) { + return nil + } + + // Step 4: Update the secret data + updateSecretData(secret, secretKey, valueToPatch) + + // Step 5: Replace sensitive values in the HTTP response + replaceSensitiveValues(data, secret, secretKey, valueToPatch) + + // Step 5: Save the updated secret to the Kubernetes API + return kubehandler.UpdateSecret(ctx, kubeClient, secret) +} + +// prepareDataMap converts an HTTP response into a map for parsing and manipulation. +func prepareDataMap(data *httpClient.HttpResponse) (map[string]interface{}, error) { + dataMap, err := json_util.StructToMap(data) + if err != nil { + return nil, errors.Wrap(err, errConvertData) + } + json_util.ConvertJSONStringsToMaps(&dataMap) + return dataMap, nil +} + +// extractValueToPatch extracts a value from a data map based on the given field path. +// If the field is a boolean, it converts it to a string. +func extractValueToPatch(dataMap map[string]interface{}, requestFieldPath string) (string, error) { + // Attempt to parse the field as a string + valueToPatch, err := jq.ParseString(requestFieldPath, dataMap) + if err == nil { + return valueToPatch, nil + } + + // Attempt to parse the field as a boolean + boolResult, boolErr := jq.ParseBool(requestFieldPath, dataMap) + if boolErr == nil { + return strconv.FormatBool(boolResult), nil + } + + // Attempt to parse the field as a number + numberResult, numberErr := jq.ParseFloat(requestFieldPath, dataMap) + if numberErr == nil { + return strconv.FormatFloat(numberResult, 'f', -1, 64), nil + } + + return "", nil +} + +// updateSecretData updates the data field of a Kubernetes Secret with the given key and value. +func updateSecretData(secret *corev1.Secret, secretKey, valueToPatch string) { + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[secretKey] = []byte(valueToPatch) +} + +// replaceSensitiveValues replaces occurrences of a sensitive value in the HTTP response body +// and headers with a placeholder. +func replaceSensitiveValues(data *httpClient.HttpResponse, secret *corev1.Secret, secretKey, valueToPatch string) { + if valueToPatch == "" { + return + } + + placeholder := fmt.Sprintf("{{%s:%s:%s}}", secret.Name, secret.Namespace, secretKey) + data.Body = strings.ReplaceAll(data.Body, valueToPatch, placeholder) + + for _, headersList := range data.Headers { + for i, header := range headersList { + headersList[i] = strings.ReplaceAll(header, valueToPatch, placeholder) + } + } +} + +// isSecretDataUpToDate checks if the specified key in the Secret already contains the given value. +func isSecretDataUpToDate(secret *corev1.Secret, secretKey, valueToPatch string) bool { + currentValue, exists := secret.Data[secretKey] + return exists && string(currentValue) == valueToPatch +} + +// syncMap synchronizes a Secret's existing map (labels or annotations) with the desired state. +// It adds or updates keys from the desired map and removes keys not present in the desired map. +// Returns true if any changes were made. +func syncMap(logger logging.Logger, existing *map[string]string, desired map[string]string, dataMap map[string]interface{}) bool { + changed := false + + // Add or update keys + for key, value := range desired { + if jq.IsJQQuery(value) { + newValue, err := extractValueToPatch(dataMap, value) + if err != nil { + logger.Info(fmt.Sprintf(errEmptyKey, value, fmt.Sprint(dataMap))) + return false + } + + if len(newValue) != 0 { + value = newValue + } + } + if (*existing)[key] != value { + (*existing)[key] = value + changed = true + } + } + + // Remove keys not in the desired map + for key := range *existing { + if _, exists := desired[key]; !exists { + delete(*existing, key) + changed = true + } + } + + return changed +} diff --git a/internal/data-patcher/secret_patcher_test.go b/internal/data-patcher/secret_patcher_test.go new file mode 100644 index 0000000..e245f35 --- /dev/null +++ b/internal/data-patcher/secret_patcher_test.go @@ -0,0 +1,500 @@ +package datapatcher + +import ( + "testing" + + httpClient "github.com/crossplane-contrib/provider-http/internal/clients/http" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIsSecretDataUpToDate(t *testing.T) { + type args struct { + secret *corev1.Secret + secretKey string + valueToPatch string + } + + type want struct { + result bool + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldReturnSecretIsUpToDate": { + args: args{ + secret: createSpecificSecret("name", "namespace", "key", "value"), + secretKey: "key", + valueToPatch: "value", + }, + want: want{ + result: true, + }, + }, + "ShouldReturnSecretIsNotUpToDate": { + args: args{ + secret: createSpecificSecret("name", "namespace", "key1", "value"), + secretKey: "key", + valueToPatch: "value", + }, + want: want{ + result: false, + }, + }, + } + + for name, tc := range cases { + tc := tc // Create local copies of loop variables + + t.Run(name, func(t *testing.T) { + got := isSecretDataUpToDate(tc.args.secret, tc.args.secretKey, tc.args.valueToPatch) + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("isUpToDate(...): -want result, +got result: %s", diff) + } + }) + } +} + +func TestSyncMap(t *testing.T) { + type args struct { + existing map[string]string + desired map[string]string + dataMap map[string]interface{} + } + + type want struct { + changed bool + expected map[string]string + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldAddAndUpdateKeys": { + args: args{ + existing: map[string]string{ + "key1": "value1", + }, + desired: map[string]string{ + "key1": "newValue1", + "key2": "value2", + }, + dataMap: map[string]interface{}{}, + }, + want: want{ + changed: true, + expected: map[string]string{ + "key1": "newValue1", + "key2": "value2", + }, + }, + }, + "ShouldRemoveKeysNotInDesired": { + args: args{ + existing: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + desired: map[string]string{ + "key1": "value1", + }, + dataMap: map[string]interface{}{}, + }, + want: want{ + changed: true, + expected: map[string]string{ + "key1": "value1", + }, + }, + }, + "ShouldProcessJQQuery": { + args: args{ + existing: map[string]string{ + "key1": "value1", + }, + desired: map[string]string{ + "key1": ".key1", // JQ query + }, + dataMap: map[string]interface{}{ + "key1": "newValueFromDataMap", + }, + }, + want: want{ + changed: true, + expected: map[string]string{ + "key1": "newValueFromDataMap", + }, + }, + }, + "ShouldDoNothingIfUpToDate": { + args: args{ + existing: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + desired: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + dataMap: map[string]interface{}{}, + }, + want: want{ + changed: false, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + }, + } + + for name, tc := range cases { + tc := tc // Create local copies of loop variables + + t.Run(name, func(t *testing.T) { + changed := syncMap(logging.NewNopLogger(), &tc.args.existing, tc.args.desired, tc.args.dataMap) + + if changed != tc.want.changed { + t.Errorf("syncMap(...): expected changed = %v, got %v", tc.want.changed, changed) + } + + if diff := cmp.Diff(tc.want.expected, tc.args.existing); diff != "" { + t.Errorf("syncMap(...): -want map, +got map: %s", diff) + } + }) + } +} + +func TestReplaceSensitiveValues(t *testing.T) { + type args struct { + data *httpClient.HttpResponse + secret *corev1.Secret + secretKey string + valueToPatch string + } + + type want struct { + body string + headers map[string][]string + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldReplaceSensitiveValueInBodyAndHeaders": { + args: args{ + data: &httpClient.HttpResponse{ + Body: "Sensitive value is here.", + Headers: map[string][]string{ + "Authorization": {"Bearer sensitive-value"}, + "Content-Type": {"application/json"}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + }, + secretKey: "sensitiveKey", + valueToPatch: "sensitive-value", + }, + want: want{ + body: "Sensitive value is here.", + headers: map[string][]string{ + "Authorization": {"Bearer {{my-secret:default:sensitiveKey}}"}, + "Content-Type": {"application/json"}, + }, + }, + }, + "ShouldDoNothingIfValueToPatchIsEmpty": { + args: args{ + data: &httpClient.HttpResponse{ + Body: "Nothing to replace here.", + Headers: map[string][]string{ + "Authorization": {"Bearer nothing"}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + }, + secretKey: "sensitiveKey", + valueToPatch: "", + }, + want: want{ + body: "Nothing to replace here.", + headers: map[string][]string{ + "Authorization": {"Bearer nothing"}, + }, + }, + }, + "ShouldHandleEmptyHeadersGracefully": { + args: args{ + data: &httpClient.HttpResponse{ + Body: "Sensitive value in the body.", + Headers: map[string][]string{ + "Authorization": {}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + }, + secretKey: "sensitiveKey", + valueToPatch: "value", + }, + want: want{ + body: "Sensitive {{my-secret:default:sensitiveKey}} in the body.", + headers: map[string][]string{ + "Authorization": {}, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + replaceSensitiveValues(tc.args.data, tc.args.secret, tc.args.secretKey, tc.args.valueToPatch) + + if diff := cmp.Diff(tc.want.body, tc.args.data.Body); diff != "" { + t.Errorf("Body mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff(tc.want.headers, tc.args.data.Headers); diff != "" { + t.Errorf("Headers mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestUpdateSecretData(t *testing.T) { + type args struct { + secret *corev1.Secret + secretKey string + valueToPatch string + } + + type want struct { + data map[string][]byte + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldAddKeyToEmptyData": { + args: args{ + secret: &corev1.Secret{}, + secretKey: "key1", + valueToPatch: "value1", + }, + want: want{ + data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + }, + "ShouldUpdateExistingKey": { + args: args{ + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key1": []byte("oldValue"), + }, + }, + secretKey: "key1", + valueToPatch: "newValue", + }, + want: want{ + data: map[string][]byte{ + "key1": []byte("newValue"), + }, + }, + }, + "ShouldAddNewKeyToExistingData": { + args: args{ + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + secretKey: "key2", + valueToPatch: "value2", + }, + want: want{ + data: map[string][]byte{ + "key1": []byte("value1"), + "key2": []byte("value2"), + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + updateSecretData(tc.args.secret, tc.args.secretKey, tc.args.valueToPatch) + + if diff := cmp.Diff(tc.want.data, tc.args.secret.Data); diff != "" { + t.Errorf("updateSecretData(...): -want data, +got data: %s", diff) + } + }) + } +} + +func TestExtractValueToPatch(t *testing.T) { + type args struct { + dataMap map[string]interface{} + requestFieldPath string + } + + type want struct { + result string + err error + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldExtractStringValue": { + args: args{ + dataMap: map[string]interface{}{ + "stringField": "testString", + }, + requestFieldPath: ".stringField", + }, + want: want{ + result: "testString", + err: nil, + }, + }, + "ShouldExtractBooleanValueAsString": { + args: args{ + dataMap: map[string]interface{}{ + "booleanField": true, + }, + requestFieldPath: ".booleanField", + }, + want: want{ + result: "true", + err: nil, + }, + }, + "ShouldExtractNumericValueAsString": { + args: args{ + dataMap: map[string]interface{}{ + "numberField": 123.45, + }, + requestFieldPath: ".numberField", + }, + want: want{ + result: "123.45", + err: nil, + }, + }, + "ShouldReturnEmptyStringIfFieldNotFound": { + args: args{ + dataMap: map[string]interface{}{ + "existingField": "value", + }, + requestFieldPath: ".nonExistentField", + }, + want: want{ + result: "", + err: nil, + }, + }, + "ShouldReturnEmptyStringIfUnsupportedType": { + args: args{ + dataMap: map[string]interface{}{ + "arrayField": []string{"value1", "value2"}, + }, + requestFieldPath: ".arrayField", + }, + want: want{ + result: "", + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + result, err := extractValueToPatch(tc.args.dataMap, tc.args.requestFieldPath) + + if diff := cmp.Diff(tc.want.result, result); diff != "" { + t.Errorf("extractValueToPatch(...): -want result, +got result: %s", diff) + } + + if (err != nil || tc.want.err != nil) && (err == nil || tc.want.err == nil || err.Error() != tc.want.err.Error()) { + t.Errorf("extractValueToPatch(...): expected err = %v, got %v", tc.want.err, err) + } + }) + } +} + +func TestPrepareDataMap(t *testing.T) { + type args struct { + data *httpClient.HttpResponse + } + + type want struct { + result map[string]interface{} + err error + } + + cases := map[string]struct { + args args + want want + }{ + "ShouldConvertHttpResponseToMap": { + args: args{ + data: &httpClient.HttpResponse{ + Body: `{"key1": "value1", "key2": {"subkey": "subvalue"}}`, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + }, + }, + want: want{ + result: map[string]interface{}{ + "body": map[string]interface{}{ + "key1": "value1", + "key2": map[string]interface{}{ + "subkey": "subvalue", + }, + }, + "headers": map[string]interface{}{ + "Content-Type": []any{"application/json"}, + }, + "statusCode": float64(0), + }, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + result, err := prepareDataMap(tc.args.data) + + if diff := cmp.Diff(tc.want.result, result); diff != "" { + t.Errorf("prepareDataMap(...): -want result, +got result: %s", diff) + } + + if (err != nil || tc.want.err != nil) && (err == nil || tc.want.err == nil || err.Error() != tc.want.err.Error()) { + t.Errorf("prepareDataMap(...): expected err = %v, got %v", tc.want.err, err) + } + }) + } +} diff --git a/internal/jq/parser.go b/internal/jq/parser.go index 6570277..9ceda89 100644 --- a/internal/jq/parser.go +++ b/internal/jq/parser.go @@ -11,6 +11,7 @@ import ( const ( errStringParseFailed = "failed to parse string: %s" + errFloatParseFailed = "failed to parse float: %s" errResultParseFailed = "failed to parse result on jq query: %s" errMapParseFailed = "failed to parse map: %s" errQueryFailed = "query should return at least one value, failed on: %s" @@ -19,6 +20,7 @@ const ( var mutex = &sync.Mutex{} +// runJQQuery runs a jq query on a given object and returns the result. func runJQQuery(jqQuery string, obj interface{}) (interface{}, error) { query, err := gojq.Parse(jqQuery) if err != nil { @@ -41,6 +43,7 @@ func runJQQuery(jqQuery string, obj interface{}) (interface{}, error) { return queryRes, nil } +// ParseString runs a jq query on a given object and returns the result as a string. func ParseString(jqQuery string, obj interface{}) (string, error) { queryRes, err := runJQQuery(jqQuery, obj) if err != nil { @@ -55,6 +58,22 @@ func ParseString(jqQuery string, obj interface{}) (string, error) { return str, nil } +// ParseFloat runs a jq query on a given object and returns the result as a float64. +func ParseFloat(jqQuery string, obj interface{}) (float64, error) { + queryRes, err := runJQQuery(jqQuery, obj) + if err != nil { + return 0, err + } + + floatVal, ok := queryRes.(float64) + if !ok { + return 0, errors.Errorf(errFloatParseFailed, fmt.Sprint(queryRes)) + } + + return floatVal, nil +} + +// ParseBool runs a jq query on a given object and returns the result as a bool. func ParseBool(jqQuery string, obj interface{}) (bool, error) { queryRes, err := runJQQuery(jqQuery, obj) if err != nil { @@ -69,6 +88,7 @@ func ParseBool(jqQuery string, obj interface{}) (bool, error) { return boolean, nil } +// ParseMapInterface runs a jq query on a given object and returns the result as a map[string]interface{}. func ParseMapInterface(jqQuery string, obj interface{}) (map[string]interface{}, error) { queryRes, err := runJQQuery(jqQuery, obj) if err != nil { @@ -88,6 +108,7 @@ func ParseMapInterface(jqQuery string, obj interface{}) (map[string]interface{}, return nil, errors.Errorf(errMapParseFailed, fmt.Sprint(queryRes)) } +// ParseMapStrings runs a jq query on a given object and returns the result as a map[string][]string. func ParseMapStrings(keyToJQQueries map[string][]string, obj interface{}) (map[string][]string, error) { result := make(map[string][]string, len(keyToJQQueries)) @@ -116,3 +137,10 @@ func ParseMapStrings(keyToJQQueries map[string][]string, obj interface{}) (map[s return result, nil } + +// IsJQQuery checks if a given string is a valid jq query. +// It attempts to compile the string as a jq expression and returns true if successful. +func IsJQQuery(query string) bool { + _, err := gojq.Parse(query) + return err == nil +} diff --git a/internal/jq/parser_test.go b/internal/jq/parser_test.go index 7a032a9..427079e 100644 --- a/internal/jq/parser_test.go +++ b/internal/jq/parser_test.go @@ -30,7 +30,7 @@ var testJQObject = map[string]any{ }, "payload": map[string]any{ "baseUrl": "https://api.example.com/users", - "body": map[string]any{"email": "john.doe@example.com", "username": "john_doe"}, + "body": map[string]any{"email": "john.doe@example.com", "username": "john_doe", "age": float64(30)}, }, "response": map[string]any{ "body": map[string]any{"id": "123"}, @@ -115,6 +115,91 @@ func Test_ParseString(t *testing.T) { } } +func Test_ParseFloat(t *testing.T) { + type args struct { + jqQuery string + obj interface{} + } + type want struct { + result interface{} + err error + } + cases := map[string]struct { + args args + want want + }{ + "SuccessFloatObject": { + args: args{ + jqQuery: `.payload.body.age`, + obj: testJQObject, + }, + want: want{ + result: float64(30), + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, gotErr := ParseFloat(tc.args.jqQuery, tc.args.obj) + if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { + t.Fatalf("ParseFloat(...): -want error, +got error: %s", diff) + } + + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Fatalf("ParseFloat(...): -want result, +got result: %s", diff) + } + }) + } +} + +func Test_ParseBool(t *testing.T) { + type args struct { + jqQuery string + obj interface{} + } + type want struct { + result interface{} + err error + } + cases := map[string]struct { + args args + want want + }{ + "SuccessBoolObjectTrue": { + args: args{ + jqQuery: `.payload.body.age == 30`, + obj: testJQObject, + }, + want: want{ + result: true, + err: nil, + }, + }, + "SuccessBoolObjectFalse": { + args: args{ + jqQuery: `.payload.body.age == 31`, + obj: testJQObject, + }, + want: want{ + result: false, + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, gotErr := ParseBool(tc.args.jqQuery, tc.args.obj) + if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { + t.Fatalf("ParseBool(...): -want error, +got error: %s", diff) + } + + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Fatalf("ParseBool(...): -want result, +got result: %s", diff) + } + }) + } +} func Test_ParseMapInterface(t *testing.T) { type args struct { jqQuery string diff --git a/package/crds/http.crossplane.io_disposablerequests.yaml b/package/crds/http.crossplane.io_disposablerequests.yaml index 8ac26a5..df2fdbb 100644 --- a/package/crds/http.crossplane.io_disposablerequests.yaml +++ b/package/crds/http.crossplane.io_disposablerequests.yaml @@ -490,14 +490,54 @@ spec: description: SecretInjectionConfig represents the configuration for injecting secret data into a Kubernetes secret. properties: + keyMappings: + description: KeyMappings allows injecting data into single + or multiple keys within the same Kubernetes secret. + items: + description: KeyInjection represents the configuration + for injecting data into a specific key in a Kubernetes + secret. + properties: + responseJQ: + description: ResponseJQ is a jq filter expression + representing the path in the response where the + secret value will be extracted from. + type: string + secretKey: + description: SecretKey is the key within the Kubernetes + secret where the data will be injected. + type: string + required: + - responseJQ + - secretKey + type: object + type: array + metadata: + description: Metadata contains labels and annotations to + apply to the Kubernetes secret. + properties: + annotations: + additionalProperties: + type: string + description: Annotations contains key-value pairs to + apply as annotations to the Kubernetes secret. + type: object + labels: + additionalProperties: + type: string + description: Labels contains key-value pairs to apply + as labels to the Kubernetes secret. + type: object + type: object responsePath: - description: ResponsePath is is a jq filter expression represents - the path in the response where the secret value will be - extracted from. + description: |- + ResponsePath is a jq filter expression representing the path in the response where the secret value will be extracted from. + Deprecated: Use KeyMappings for injecting single or multiple keys. type: string secretKey: - description: SecretKey is the key within the Kubernetes - secret where the data will be injected. + description: |- + SecretKey is the key within the Kubernetes secret where the data will be injected. + Deprecated: Use KeyMappings for injecting single or multiple keys. type: string secretRef: description: SecretRef contains the name and namespace of @@ -519,8 +559,6 @@ spec: the owner reference on the Kubernetes secret. type: boolean required: - - responsePath - - secretKey - secretRef type: object type: array diff --git a/package/crds/http.crossplane.io_requests.yaml b/package/crds/http.crossplane.io_requests.yaml index 0aa4c0a..b511374 100644 --- a/package/crds/http.crossplane.io_requests.yaml +++ b/package/crds/http.crossplane.io_requests.yaml @@ -571,14 +571,54 @@ spec: description: SecretInjectionConfig represents the configuration for injecting secret data into a Kubernetes secret. properties: + keyMappings: + description: KeyMappings allows injecting data into single + or multiple keys within the same Kubernetes secret. + items: + description: KeyInjection represents the configuration + for injecting data into a specific key in a Kubernetes + secret. + properties: + responseJQ: + description: ResponseJQ is a jq filter expression + representing the path in the response where the + secret value will be extracted from. + type: string + secretKey: + description: SecretKey is the key within the Kubernetes + secret where the data will be injected. + type: string + required: + - responseJQ + - secretKey + type: object + type: array + metadata: + description: Metadata contains labels and annotations to + apply to the Kubernetes secret. + properties: + annotations: + additionalProperties: + type: string + description: Annotations contains key-value pairs to + apply as annotations to the Kubernetes secret. + type: object + labels: + additionalProperties: + type: string + description: Labels contains key-value pairs to apply + as labels to the Kubernetes secret. + type: object + type: object responsePath: - description: ResponsePath is is a jq filter expression represents - the path in the response where the secret value will be - extracted from. + description: |- + ResponsePath is a jq filter expression representing the path in the response where the secret value will be extracted from. + Deprecated: Use KeyMappings for injecting single or multiple keys. type: string secretKey: - description: SecretKey is the key within the Kubernetes - secret where the data will be injected. + description: |- + SecretKey is the key within the Kubernetes secret where the data will be injected. + Deprecated: Use KeyMappings for injecting single or multiple keys. type: string secretRef: description: SecretRef contains the name and namespace of @@ -600,8 +640,6 @@ spec: the owner reference on the Kubernetes secret. type: boolean required: - - responsePath - - secretKey - secretRef type: object type: array