From b828e242b9b3748f869d8f36dcdc75d45780a14e Mon Sep 17 00:00:00 2001 From: Matthias Rinck Date: Thu, 30 Nov 2023 15:25:46 +0100 Subject: [PATCH 1/4] add secret template --- README.md | 254 ++++-- api/v1/servicebinding_types.go | 11 + api/v1/servicebinding_validating_webhook.go | 14 + .../servicebinding_validating_webhook_test.go | 37 + api/v1/suite_test.go | 6 + ...ervices.cloud.sap.com_servicebindings.yaml | 8 + controllers/servicebinding_controller.go | 89 +- controllers/servicebinding_controller_test.go | 128 ++- go.mod | 16 +- go.sum | 51 ++ internal/ioutils/error_conv_writer.go | 22 + internal/ioutils/error_conv_writer_test.go | 91 ++ internal/ioutils/go_generate_for_test.go | 3 + internal/ioutils/io_mocks_for_test.go | 49 ++ internal/ioutils/ioutils_suite_test.go | 13 + internal/ioutils/limited_writer.go | 52 ++ internal/ioutils/limited_writer_test.go | 805 ++++++++++++++++++ .../secrets/template/allowed_sprig_funcs.go | 262 ++++++ internal/secrets/template/template.go | 116 +++ .../secrets/template/template_suite_test.go | 13 + internal/secrets/template/template_test.go | 171 ++++ sapbtp-operator-charts/templates/crd.yml | 9 + 22 files changed, 2111 insertions(+), 109 deletions(-) create mode 100644 internal/ioutils/error_conv_writer.go create mode 100644 internal/ioutils/error_conv_writer_test.go create mode 100644 internal/ioutils/go_generate_for_test.go create mode 100644 internal/ioutils/io_mocks_for_test.go create mode 100644 internal/ioutils/ioutils_suite_test.go create mode 100644 internal/ioutils/limited_writer.go create mode 100644 internal/ioutils/limited_writer_test.go create mode 100644 internal/secrets/template/allowed_sprig_funcs.go create mode 100644 internal/secrets/template/template.go create mode 100644 internal/secrets/template/template_suite_test.go create mode 100644 internal/secrets/template/template_test.go diff --git a/README.md b/README.md index e1bec0f4..c7c7de21 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ # SAP Business Technology Platform (SAP BTP) Service Operator for Kubernetes -With the SAP BTP service operator, you can consume [SAP BTP services](https://platformx-d8bd51250.dispatcher.us2.hana.ondemand.com/protected/index.html#/viewServices?) from your Kubernetes cluster using Kubernetes-native tools. -SAP BTP service operator allows you to provision and manage service instances and service bindings of SAP BTP services so that your Kubernetes-native applications can access and use needed services from the cluster. +With the SAP BTP service operator, you can consume [SAP BTP services](https://platformx-d8bd51250.dispatcher.us2.hana.ondemand.com/protected/index.html#/viewServices?) from your Kubernetes cluster using Kubernetes-native tools. +SAP BTP service operator allows you to provision and manage service instances and service bindings of SAP BTP services so that your Kubernetes-native applications can access and use needed services from the cluster. The SAP BTP service operator is based on the [Kubernetes Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/). ## Table of Contents @@ -22,8 +22,9 @@ The SAP BTP service operator is based on the [Kubernetes Operator pattern](https * [Service instance properties](#service-instance) * [Binding properties](#service-binding) * [Passing parameters](#passing-parameters) + * [Creating Custom Secrets from Templates](#creating-custom-secrets-from-templates) * [Managing access](#managing-access) -* [SAP BTP kubectl Extension](#sap-btp-kubectl-plugin-experimental) +* [SAP BTP kubectl Extension](#sap-btp-kubectl-plugin-experimental) * [Credentials Rotation](#credentials-rotation) * [Multitenancy](#multitenancy) * [Troubleshooting and Support](#troubleshooting-and-support) @@ -37,9 +38,9 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten ![img](./docs/images/architecture.png) ## Prerequisites -- SAP BTP [Global Account](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/d61c2819034b48e68145c45c36acba6e.html) and [Subaccount](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/55d0b6d8b96846b8ae93b85194df0944.html) +- SAP BTP [Global Account](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/d61c2819034b48e68145c45c36acba6e.html) and [Subaccount](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/55d0b6d8b96846b8ae93b85194df0944.html) - Service Management Control (SMCTL) command line interface. See [Using the SMCTL](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/0107f3f8c1954a4e96802f556fc807e3.html). -- [Kubernetes cluster](https://kubernetes.io/) running version 1.17 or higher +- [Kubernetes cluster](https://kubernetes.io/) running version 1.17 or higher - [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) v1.17 or higher - [helm](https://helm.sh/) v3.0 or higher @@ -47,7 +48,7 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten ## Setup 1. Install [cert-manager](https://cert-manager.io/docs/installation/kubernetes) - - for releases v0.1.18 or higher use cert manager v1.6.0 or higher + - for releases v0.1.18 or higher use cert manager v1.6.0 or higher - for releases v0.1.17 or lower use cert manager lower then v1.6.0 2. Obtain the access credentials for the SAP BTP service operator: @@ -57,24 +58,24 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten *For more information about how to entitle a service to a subaccount, see:* * *[Configure Entitlements and Quotas for Subaccounts](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/5ba357b4fa1e4de4b9fcc4ae771609da.html)* - - -
For more information about creating service instances, see: + + +
For more information about creating service instances, see: * [Creating Service Instances Using the SAP BTP Cockpit](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/bf71f6a7b7754dbd9dfc2569791ccc96.html) - - * [Creating Service Instances using SMCTL](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/b327b66b711746b085ec5d2ea16e608e.html)
- + + * [Creating Service Instances using SMCTL](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/b327b66b711746b085ec5d2ea16e608e.html)
+ b. Create a binding to the created service instance. - - For more information about creating service bindings, see: - * [Creating Service Bindings Using the SAP BTP Cockpit](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/55b31ea23c474f6ba2f64ee4848ab1b3.html) - - * [Creating Service Bindings Using SMCTL](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/f53ff2634e0a46d6bfc72ec075418dcd.html). - + + For more information about creating service bindings, see: + * [Creating Service Bindings Using the SAP BTP Cockpit](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/55b31ea23c474f6ba2f64ee4848ab1b3.html) + + * [Creating Service Bindings Using SMCTL](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/f53ff2634e0a46d6bfc72ec075418dcd.html). + c. Retrieve the generated access credentials from the created binding: - + The example of the default binding object used if no credentials type is specified: - + ```json { "clientid": "xxxxxxx", @@ -85,7 +86,7 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten } ``` The example of the binding object with the specified X.509 credentials type: - + ```json { "clientid": "xxxxxxx", @@ -96,15 +97,15 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten "sm_url": "https://service-manager.cfapps.eu10.hana.ondemand.com" } ``` -3. Add SAP BTP service operator chart repository +3. Add SAP BTP service operator chart repository ```bash helm repo add sap-btp-operator https://sap.github.io/sap-btp-service-operator ``` 4. Deploy the the SAP BTP service operator in the cluster using the obtained access credentials:
- + *Note:
If you are deploying the SAP BTP service operator in the registered cluster based on the Service Catalog (svcat) and Service Manager agent so that you can migrate svcat-based content to service operator-based content, add ```--set cluster.id= ``` to your deployment script.*
*For more information, see the step 2 of the Setup section of [Migration to SAP BTP service operator](https://github.com/SAP/sap-btp-service-operator-migration/blob/main/README.md).* - + The example of the deployment that uses the default access credentials type: ```bash helm upgrade --install sap-btp-operator/sap-btp-operator \ @@ -127,7 +128,7 @@ It is implemented using a [CRDs-based](https://kubernetes.io/docs/concepts/exten --set manager.secret.tokenurl= ``` **Note:**
In order to rotate the credentials between the BTP service operator and Service Manager, you have to create a new binding for the service-operator-access service instance, and then to execute the setup script again, with the new set of credentials. Afterwards you can delete the old binding. - + [Back to top](#sap-business-technology-platform-sap-btp-service-operator-for-kubernetes). @@ -159,9 +160,9 @@ Review the supported Kubernetes API versions for the following SAP BTP Service O key2: val2 ``` - * `` - The name of the SAP BTP service that you want to create. - To learn more about viewing and managing the available services for your subaccount in the SAP BTP cockpit, see [Service Marketplace](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/affcc245c332433ba71917ff715b9971.html). - + * `` - The name of the SAP BTP service that you want to create. + To learn more about viewing and managing the available services for your subaccount in the SAP BTP cockpit, see [Service Marketplace](https://help.sap.com/viewer/09cc82baadc542a688176dce601398de/Cloud/en-US/affcc245c332433ba71917ff715b9971.html). + Tip: Use the *Environment* filter to get all offerings that are relevant for Kubernetes. * `` - The plan of the selected service offering that you want to create. @@ -197,7 +198,7 @@ spec: secretName: my-secret parameters: key1: val1 - key2: val2 + key2: val2 ``` 2. Apply the custom resource file in your cluster to create the binding. @@ -212,7 +213,7 @@ spec: kubectl get servicebindings NAME INSTANCE STATUS AGE my-binding my-service-instance Created 16s - + ``` 4. Check that a secret with the same name as the name of your binding is created. The secret contains the service credentials that apps in your cluster can use to access the service. @@ -222,8 +223,8 @@ spec: NAME TYPE DATA AGE my-binding Opaque 5 32s ``` - - See [Using Secrets](https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets) to learn about different options on how to use the credentials from your application running in the Kubernetes cluster, + + See [Using Secrets](https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets) to learn about different options on how to use the credentials from your application running in the Kubernetes cluster, [Back to top](#sap-business-technology-platform-sap-btp-service-operator-for-kubernetes) @@ -257,7 +258,7 @@ spec: |:-----------------|:---------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | services.cloud.sap.com/preventDeletion | `map[string] string` | You can prevent deletion of any service instance by adding the following annotation: services.cloud.sap.com/preventDeletion : "true". To enable back the deletion of the instance, either remove the annotation or set it to false. | -### Service Binding +### Service Binding #### Spec | Parameter | Type | Description | |:-----------------|:---------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -268,6 +269,7 @@ spec: | secretRootKey | `string` | The root key is a part of the Secret object, which stores service binding data (credentials) received from the broker, as well as additional service instance information. When the root key is used, all data is stored under a single key. This makes it a convenient way to store data in one file when using volumeMounts. [Example](#formats-of-secret-objects) | | parameters | `[]object` | Some services support the provisioning of additional configuration parameters during the bind request.
For the list of supported parameters, check the documentation of the particular service offering. | | parametersFrom | `[]object` | List of sources to populate parameters. | +| secretTemplate | `string` | A [Go template](https://pkg.go.dev/text/template) that generates a custom Kubernetes v1/Secret based on the data of the service binding returned by Service Manager. The generated secret is used instead of the default secret. This is useful if the consumer of service binding data expects them in a specific format.
Also see [_Creating Custom Secrets from Templates_](#creating-custom-secrets-from-templates) below. | | userInfo | `object` | Contains information about the user that last modified this service binding. | | credentialsRotationPolicy | `object` | Holds automatic credentials rotation configuration. | | credentialsRotationPolicy.enabled | `boolean` | Indicates whether automatic credentials rotation are enabled. | @@ -368,6 +370,116 @@ secret-parameter: ``` [Back to top](#sap-business-technology-platform-sap-btp-service-operator-for-kubernetes). +### Creating Custom Secrets from Templates + +The Kubernetes secrets created by SAP BTP Service Operator for service bindings have a fixed content structure. +This structure might not be suitable for any application. + +To provide service binding data in _arbitrarily_ structured Kubernetes secrets, a [Go template](https://pkg.go.dev/text/template) can be specified in field `spec.secretTemplate` of a `ServiceBinding` object. + +Once SAP BTP Service Operator has received a service binding from Service Manager, it executes the template with the data of the service binding to generate a Kubernetes v1/Secret object. + +The template can use the following data: + +| Reference | Description | +|-|-| +| `.smBindingCredentials` | The binding credentials object returned by Service Manager. The content depends on the service. | +| `.serviceInstanceInfos` | An object with data about the corresponding service instance (see below) | +| `.serviceInstanceInfos.instance_name` | The service instance name. | +| `.serviceInstanceInfos.instance_guid` | The service instance UID. | +| `.serviceInstanceInfos.plan` | The service plan name. | +| `.serviceInstanceInfos.label` | The service offering name. | +| `.serviceInstanceInfos.type` | The service offering name. | +| `.serviceInstanceInfos.tag` | The combination of tags under `ServiceInstance.Spec.CustomTags` and `ServiceInstance.Status.Tags` in JSON format. | + +[Sprig template functions](https://masterminds.github.io/sprig/) are also available, except of the following: + +* `bcrypt` +* `buildCustomCert` +* `decryptAES` +* `derivePassword` +* `encryptAES` +* `env` +* `expandenv` +* `genCA` +* `genCAWithKey` +* `genPrivateKey` +* `genSelfSignedCert` +* `genSelfSignedCertWithKey` +* `genSignedCert` +* `genSignedCertWithKey` +* `getHostByName` +* `htpasswd` +* `osBase` +* `osClean` +* `osDir` +* `osExt` +* `osIsAbs` +* `randBytes` + +#### Example + +```yaml +apiVersion: services.cloud.sap.com/v1 +kind: ServiceBinding +... +spec: + ... + secretName: myapp-foo-access + secretTemplate: |- + apiVersion: v1 + kind: Secret + metadata: + labels: + app: my-app + stringData: + FOO_USERNAME: {{ .smBindingCredentials.username | squote }} + FOO_PASSWORD: {{ .smBindingCredentials.password | squote }} +``` + +`.smBindingCredentials` may look like this: + +```json +{ + "username": "foo-user1", + "password": "topsecret" +} +``` + +The secret generated by the template and completed by SAP BTP Service Operator looks like this: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: myapp-foo-access + labels: + app: my-app + ... +stringData: + USERNAME: 'foo-user1' + PASSWORD: 'topsecret' +``` + +#### Limitations + +##### Metadata Section + +Secret manifests generated by templates _must not_ contain any metadata fields except: + +- `labels` +- `annotations` + +If any other metadata field is found, an error occurs and the secret is not stored in Kubernetes. + +The _name_ of a secret must be defined using field `spec.secretName` (see above). + +##### Manifest Size + +The size of a manifest generated from a secret template must not exceed 1 MiB. +If this limit is exceeded, an error occurs and the secret is not stored in Kubernetes. + + ### Managing Access By default, the SAP BTP operator has cluster-wide permissions.
You can also limit them to one or more namespaces; for this, you need to set the following two helm parameters: @@ -401,7 +513,7 @@ kubectl sapbtp plans -n kubectl sapbtp services -n ``` -Use the `namespace` parameter to specify the location of the secret containing the SAP BTP access credentials. +Use the `namespace` parameter to specify the location of the secret containing the SAP BTP access credentials. Usually it is the namespace in which you installed the operator. If not specified, the `default` namespace is used. @@ -410,8 +522,8 @@ If not specified, the `default` namespace is used. ## Credentials Rotation To enable automatic credentials rotation, you need to set the following parameters of the `credentialsRotationPolicy` field in the `spec` field of the `ServiceBinding` resource: -- `enabled` - Whether the credentials rotation option is enabled. Default value is false. -- `rotationFrequency` - Indicates the frequency at which the credentials rotation is performed. +- `enabled` - Whether the credentials rotation option is enabled. Default value is false. +- `rotationFrequency` - Indicates the frequency at which the credentials rotation is performed. - `rotatedBindingTTL` - Indicates for how long to keep the rotated `ServiceBinding`. Valid time units for `rotationFrequency` and `rotatedBindingTTL` are: "ns", "us" or ("µs"), "ms", "s", "m", "h".

@@ -435,7 +547,7 @@ To connect the namespace to a subaccount, you first have to obtain the [access c There are two options to maintain namespace-specific credentials, and they differ between default and TLS-based access credentials types: ### Default Access Credentials -- Define a secret named `sap-btp-service-operator` in the namespace. `ServiceInstance` and `ServiceBinding` that are applied in the namespace will belong to the subaccount from which the credentials were issued. +- Define a secret named `sap-btp-service-operator` in the namespace. `ServiceInstance` and `ServiceBinding` that are applied in the namespace will belong to the subaccount from which the credentials were issued. - Define different secrets for different namespaces in a [centrally managed namespace](./sapbtp-operator-charts/templates/configmap.yml), following the secret naming convention: `-sap-btp-service-operator`. #### Namespace Secret Structure ```yaml @@ -454,7 +566,7 @@ data: ``` ### TLS-Based Access Credentials -- Define a secret pair named `sap-btp-service-operator` and `sap-btp-service-operator-tls` in the namespace. `ServiceInstance` and `ServiceBinding` that are applied in the namespace will belong to the subaccount from which the credentials were issued. +- Define a secret pair named `sap-btp-service-operator` and `sap-btp-service-operator-tls` in the namespace. `ServiceInstance` and `ServiceBinding` that are applied in the namespace will belong to the subaccount from which the credentials were issued. - Define different secrets for different namespaces in a [centrally managed namespace](./sapbtp-operator-charts/templates/configmap.yml), following the secret naming convention: `-sap-btp-service-operator` and `-sap-btp-service-operator-tls`. For more information, see [tls secret](./sapbtp-operator-charts/templates/secret-tls.yml). #### Namespace Secrets Structure ```yaml @@ -485,17 +597,17 @@ data: **Notes:** - If none of the those mentioned above options are set, `sap-btp-service-operator` secret of a release namespace is used.
See step 4 of the [Setup](#setup) section. - + [Back to top](#sap-business-technology-platform-sap-btp-service-operator-for-kubernetes) ## Troubleshooting and Support - #### Cannot Create a Service Binding for Service Instance in `Delete Failed` State + #### Cannot Create a Service Binding for Service Instance in `Delete Failed` State The deletion of my service instance failed. To fix the failure, I have to create a service binding, but I can't do this because the instance is in the `Delete Failed` state. - - **Solution** - + + **Solution** + To overcome this issue, use the `force_k8s_binding` query param when you create a service binding and set it to `true` (`force_k8s_binding=true`). You can do & this either with the Service Manager Control CLI (smctl) [bind](https://help.sap.com/docs/SERVICEMANAGEMENT/09cc82baadc542a688176dce601398de/f53ff2634e0a46d6bfc72ec075418dcd.html) command or 'Create a Service Binding' [Service Manager API](https://api.sap.com/api/APIServiceManagment/resource). smctl Example @@ -513,8 +625,8 @@ data: > ``` **Note:** `force_k8s_binding` is supported only for the Kubernetes instances that are in `Delete Failed` state.
-You're welcome to raise issues related to feature requests, bugs, or give us general feedback on this project's GitHub Issues page. -The SAP BTP service operator project maintainers will respond to the best of their abilities. +You're welcome to raise issues related to feature requests, bugs, or give us general feedback on this project's GitHub Issues page. +The SAP BTP service operator project maintainers will respond to the best of their abilities. [Back to top](#sap-business-technology-platform-sap-btp-service-operator-for-kubernetes) @@ -531,12 +643,12 @@ password: ******** #Service instance info instance_guid: // The service instance ID instance_name: my-service-btp-name // Taken from the service instance external_name field if set. Otherwise from metadata.name -plan: sample-plan // The service plan name +plan: sample-plan // The service plan name type: sample-service // The service offering name ``` ### Credentials as JSON Object -To show credentials returned from the broker as a JSON object, use the 'secretKey' attribute in the service binding spec. +To show credentials returned from the broker as a JSON object, use the 'secretKey' attribute in the service binding spec. The value of 'secretKey' is the name of the key that stores the credentials in JSON format. @@ -551,7 +663,7 @@ your-secretKey-value: #Service Instance info instance_guid: // The service instance ID -instance_name: my-service-btp-name // Taken from the service instance external_name field if set. Otherwise from metadata.name +instance_name: my-service-btp-name // Taken from the service instance external_name field if set. Otherwise from metadata.name plan: sample-plan // The service plan name type: sample-service // The service offering name ``` @@ -568,10 +680,10 @@ your-secretRootKey-value: uri: https://my-service.authentication.eu10.hana.ondemand.com username: admin password: ******** - + #Service Instance info instance_guid: // The service instance id - instance_name: my-service-btp-name // Taken from the service instance external_name field if set. Otherwise from metadata.name + instance_name: my-service-btp-name // Taken from the service instance external_name field if set. Otherwise from metadata.name plan: sample-plan // The service plan name type: sample-service // The service offering name } @@ -586,7 +698,7 @@ Before you uninstall the operator, we recommend you manually delete all associat To uninstall the operator, run the following command: `helm uninstall -n ` -Example: +Example: > ``` > helm uninstall sap-btp-operator -n sap-btp-operator @@ -595,45 +707,45 @@ Example: - `release uninstalled` - The operator has been successfully uninstalled - - `Timed out waiting for condition` - + - `Timed out waiting for condition` + - What happened? - + The deletion of instances and bindings takes more than 5 minutes, this happens when there is a large number of instances and bindings. - What to do: - + Wait for the job to finish and re-trigger the uninstall process. To check the job status, run `kubectl get jobs --namespace=` or log on to the cluster and check the job log. Note that you may have to repeat this step several times untill the un-install process has been successfully completed. - - + + - `job failed: BackoffLimitExceeded` - + - What happened? - + One of the service instances or bindings could not be deleted. - + - What to do: - - First find the service instance or binding in question and fix it, then re-trigger the uninstalation. + + First find the service instance or binding in question and fix it, then re-trigger the uninstalation. To find it, log on to the cluster and check the pre-delete job, or check the logs by running the following two commands: - + - `kubectl get pods --all-namespaces| grep pre-delete` - which gives you the list of all namespaces and jobs - `kubectl logs --namespace=` - where you specify the desired job and namespace - - Note that the pre-delete job is only visible for approximately one minute after the job execution is completed. -  + + Note that the pre-delete job is only visible for approximately one minute after the job execution is completed. +  If you don't have an access to the pre-delete job, use kubectl to view details about the failed resource and check its status by running: - - - `kubectl describe ` - - Check for resources with the deletion timestamp to determine if it tried to be deleted. + + - `kubectl describe ` + + Check for resources with the deletion timestamp to determine if it tried to be deleted. ## Contributions -We currently do not accept community contributions. +We currently do not accept community contributions. ## License This project is licensed under Apache 2.0 unless noted otherwise in the [license](./LICENSE) file. diff --git a/api/v1/servicebinding_types.go b/api/v1/servicebinding_types.go index 86c3e638..e9221b93 100644 --- a/api/v1/servicebinding_types.go +++ b/api/v1/servicebinding_types.go @@ -88,6 +88,17 @@ type ServiceBindingSpec struct { // CredentialsRotationPolicy holds automatic credentials rotation configuration. // +optional CredRotationPolicy *CredentialsRotationPolicy `json:"credentialsRotationPolicy,omitempty"` + + // SecretTemplate is a Go template that generates a custom Kubernetes + // v1/Secret based on the data of the service binding returned by + // Service Manager. + // The generated secret is used instead of the default secret. + // This is useful if the consumer of service binding data expects them in + // a specific format. + // For Go templates see https://pkg.go.dev/text/template. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + SecretTemplate string `json:"secretTemplate,omitempty"` } // ServiceBindingStatus defines the observed state of ServiceBinding diff --git a/api/v1/servicebinding_validating_webhook.go b/api/v1/servicebinding_validating_webhook.go index 04df9b93..431ade55 100644 --- a/api/v1/servicebinding_validating_webhook.go +++ b/api/v1/servicebinding_validating_webhook.go @@ -21,6 +21,7 @@ import ( "reflect" "time" + "github.com/pkg/errors" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -28,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/SAP/sap-btp-service-operator/api" + "github.com/SAP/sap-btp-service-operator/internal/secrets/template" ) // log is for logging in this package. @@ -54,6 +56,11 @@ func (sb *ServiceBinding) ValidateCreate() (admission.Warnings, error) { return nil, err } } + if sb.Spec.SecretTemplate != "" { + if err := sb.validateSecretTemplate(); err != nil { + return nil, errors.Wrap(err, "spec.secretTemplate is invalid") + } + } return nil, nil } @@ -124,3 +131,10 @@ func (sb *ServiceBinding) validateCredRotatingConfig() error { return nil } + +func (sb *ServiceBinding) validateSecretTemplate() error { + servicebindinglog.Info("validate specified secretTemplate") + + _, err := template.ParseTemplate("", sb.Spec.SecretTemplate) + return err +} diff --git a/api/v1/servicebinding_validating_webhook_test.go b/api/v1/servicebinding_validating_webhook_test.go index b9ae8aab..2ef77755 100644 --- a/api/v1/servicebinding_validating_webhook_test.go +++ b/api/v1/servicebinding_validating_webhook_test.go @@ -2,6 +2,7 @@ package v1 import ( "github.com/SAP/sap-btp-service-operator/api" + "github.com/lithammer/dedent" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/runtime" @@ -19,6 +20,29 @@ var _ = Describe("Service Binding Webhook Test", func() { _, err := binding.ValidateCreate() Expect(err).ToNot(HaveOccurred()) }) + It("should succeed if secretTemplate can be parsed", func() { + binding.Spec.SecretTemplate = dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + secretKey: {{ .secretValue | quote }}`) + + _, err := binding.ValidateCreate() + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should fail if secretTemplate cannot be parsed", func() { + binding.Spec.SecretTemplate = dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + secretKey: {{ .secretValue | quote`) + + _, err := binding.ValidateCreate() + + Expect(err).Should(MatchError(ContainSubstring("spec.secretTemplate is invalid"))) + }) }) Context("Validate update of spec before binding is created (failure recovery)", func() { @@ -61,6 +85,19 @@ var _ = Describe("Service Binding Webhook Test", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + When("SecretTemplate changed", func() { + It("should succeed", func() { + modifiedSecretTemplate := ` + apiVersion: v1 + kind: Secret + stringData: + key2: "value2"` + newBinding.Spec.SecretTemplate = modifiedSecretTemplate + _, err := newBinding.ValidateUpdate(binding) + Expect(err).ToNot(HaveOccurred()) + }) + }) }) When("Metadata changed", func() { diff --git a/api/v1/suite_test.go b/api/v1/suite_test.go index 39dfb954..6f1e77e0 100644 --- a/api/v1/suite_test.go +++ b/api/v1/suite_test.go @@ -3,6 +3,7 @@ package v1 import ( "testing" + "github.com/lithammer/dedent" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" v1 "k8s.io/api/authentication/v1" @@ -47,6 +48,11 @@ func getBinding() *ServiceBinding { RotationFrequency: "1s", RotatedBindingTTL: "1s", }, + SecretTemplate: dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + key1: value1`), }, Status: ServiceBindingStatus{}, diff --git a/config/crd/bases/services.cloud.sap.com_servicebindings.yaml b/config/crd/bases/services.cloud.sap.com_servicebindings.yaml index 7b1110bd..3e9bfc65 100644 --- a/config/crd/bases/services.cloud.sap.com_servicebindings.yaml +++ b/config/crd/bases/services.cloud.sap.com_servicebindings.yaml @@ -125,6 +125,14 @@ spec: and additional info under single key. Convenient way to store whole binding data in single file when using `volumeMounts`. type: string + secretTemplate: + description: SecretTemplate is a Go template that generates a custom + Kubernetes v1/Secret based on the data of the service binding returned + by Service Manager. The generated secret is used instead of the + default secret. This is useful if the consumer of service binding + data expects them in a specific format. For Go templates see https://pkg.go.dev/text/template. + type: string + x-kubernetes-preserve-unknown-fields: true serviceInstanceName: description: The k8s name of the service instance to bind, should be in the namespace of the binding diff --git a/controllers/servicebinding_controller.go b/controllers/servicebinding_controller.go index 76e583bb..86fce419 100644 --- a/controllers/servicebinding_controller.go +++ b/controllers/servicebinding_controller.go @@ -27,6 +27,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller" servicesv1 "github.com/SAP/sap-btp-service-operator/api/v1" + "github.com/SAP/sap-btp-service-operator/internal/secrets/template" + "github.com/pkg/errors" "github.com/SAP/sap-btp-service-operator/api" @@ -49,8 +51,10 @@ import ( ) const ( - secretNameTakenErrorFormat = "the specified secret name '%s' is already taken. Choose another name and try again" - secretAlreadyOwnedErrorFormat = "secret %s belongs to another binding %s, choose a different name" + secretNameTakenErrorFormat = "the specified secret name '%s' is already taken. Choose another name and try again" + secretAlreadyOwnedErrorFormat = "secret %s belongs to another binding %s, choose a different name" + secretTemplateSmBindingKey = "smBindingCredentials" + secretTemplateServiceInstanceInfos = "serviceInstanceInfos" ) // ServiceBindingReconciler reconciles a ServiceBinding object @@ -538,16 +542,81 @@ func (r *ServiceBindingReconciler) resyncBindingStatus(ctx context.Context, k8sB func (r *ServiceBindingReconciler) storeBindingSecret(ctx context.Context, k8sBinding *servicesv1.ServiceBinding, smBinding *smClientTypes.ServiceBinding) error { log := GetLogger(ctx) logger := log.WithValues("bindingName", k8sBinding.Name, "secretName", k8sBinding.Spec.SecretName) + var secret *corev1.Secret + var err error + if k8sBinding.Spec.SecretTemplate != "" { + secret, err = r.createBindingSecretFromSecretTemplate(ctx, k8sBinding, smBinding.Credentials) + } else { + secret, err = r.createBindingSecret(ctx, k8sBinding, smBinding.Credentials) + } + + if err != nil { + return err + } + + if err := controllerutil.SetControllerReference(k8sBinding, secret, r.Scheme); err != nil { + logger.Error(err, "Failed to set secret owner") + return err + } + + return r.createOrUpdateBindingSecret(ctx, k8sBinding, secret) +} + +// createBindingSecretFromSecretTemplate executes the template of .Spec.SecretTemplate +func (r *ServiceBindingReconciler) createBindingSecretFromSecretTemplate(ctx context.Context, k8sBinding *servicesv1.ServiceBinding, inputSmCredentials json.RawMessage) (*corev1.Secret, error) { + log := GetLogger(ctx) + log.Info("Create Object using SecretTemplate from ServiceBinding Specs") + + smBindingCredentials := make(map[string]interface{}) + if inputSmCredentials != nil { + err := json.Unmarshal(inputSmCredentials, &smBindingCredentials) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal given service binding credentials") + } + } + + instanceInfos := make(map[string][]byte) + _, err := r.addInstanceInfo(ctx, k8sBinding, instanceInfos) + if err != nil { + return nil, errors.Wrap(err, "failed to add service instance info") + } + + //convert the bytes to string to ensure, that the secret can be created later by CreateSecretFromTemplate + convertedInstanceInfos := make(map[string]string) + for k, v := range instanceInfos { + convertedInstanceInfos[k] = string(v) + } + + parameters := map[string]interface{}{ + secretTemplateSmBindingKey: smBindingCredentials, + secretTemplateServiceInstanceInfos: convertedInstanceInfos, + } + + templateName := fmt.Sprintf("%s/%s", k8sBinding.Namespace, k8sBinding.Name) + secret, err := template.CreateSecretFromTemplate(templateName, k8sBinding.Spec.SecretTemplate, parameters) + if err != nil { + return nil, errors.Wrap(err, "failed to create secret from template") + } + + secret.SetNamespace(k8sBinding.Namespace) + secret.SetName(k8sBinding.Spec.SecretName) + + return secret, nil +} + +func (r *ServiceBindingReconciler) createBindingSecret(ctx context.Context, k8sBinding *servicesv1.ServiceBinding, credentials json.RawMessage) (*corev1.Secret, error) { + log := GetLogger(ctx) + logger := log.WithValues("bindingName", k8sBinding.Name, "secretName", k8sBinding.Spec.SecretName) var credentialsMap map[string][]byte var credentialProperties []SecretMetadataProperty - if len(smBinding.Credentials) == 0 { + if len(credentials) == 0 { log.Info("Binding credentials are empty") credentialsMap = make(map[string][]byte) } else if k8sBinding.Spec.SecretKey != nil { credentialsMap = map[string][]byte{ - *k8sBinding.Spec.SecretKey: smBinding.Credentials, + *k8sBinding.Spec.SecretKey: credentials, } credentialProperties = []SecretMetadataProperty{ { @@ -558,10 +627,10 @@ func (r *ServiceBindingReconciler) storeBindingSecret(ctx context.Context, k8sBi } } else { var err error - credentialsMap, credentialProperties, err = normalizeCredentials(smBinding.Credentials) + credentialsMap, credentialProperties, err = normalizeCredentials(credentials) if err != nil { logger.Error(err, "Failed to store binding secret") - return fmt.Errorf("failed to create secret. Error: %v", err.Error()) + return nil, fmt.Errorf("failed to create secret. Error: %v", err.Error()) } } @@ -574,7 +643,7 @@ func (r *ServiceBindingReconciler) storeBindingSecret(ctx context.Context, k8sBi var err error credentialsMap, err = singleKeyMap(credentialsMap, *k8sBinding.Spec.SecretRootKey) if err != nil { - return err + return nil, err } } else { metadata := map[string][]SecretMetadataProperty{ @@ -597,12 +666,8 @@ func (r *ServiceBindingReconciler) storeBindingSecret(ctx context.Context, k8sBi }, Data: credentialsMap, } - if err := controllerutil.SetControllerReference(k8sBinding, secret, r.Scheme); err != nil { - logger.Error(err, "Failed to set secret owner") - return err - } - return r.createOrUpdateBindingSecret(ctx, k8sBinding, secret) + return secret, nil } func (r *ServiceBindingReconciler) createOrUpdateBindingSecret(ctx context.Context, binding *servicesv1.ServiceBinding, secret *corev1.Secret) error { diff --git a/controllers/servicebinding_controller_test.go b/controllers/servicebinding_controller_test.go index 71806936..d4047360 100644 --- a/controllers/servicebinding_controller_test.go +++ b/controllers/servicebinding_controller_test.go @@ -15,6 +15,7 @@ import ( "github.com/SAP/sap-btp-service-operator/client/sm/smfakes" smClientTypes "github.com/SAP/sap-btp-service-operator/client/sm/types" "github.com/google/uuid" + "github.com/lithammer/dedent" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -45,8 +46,8 @@ var _ = Describe("ServiceBinding controller", func() { instanceExternalName string ) - createBindingWithoutAssertionsAndWait := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName string, wait bool) (*v1.ServiceBinding, error) { - binding := generateBasicBindingTemplate(name, namespace, instanceName, instanceNamespace, externalName) + createBindingWithoutAssertionsAndWait := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName, secretTemplate string, wait bool) (*v1.ServiceBinding, error) { + binding := generateBasicBindingTemplate(name, namespace, instanceName, instanceNamespace, externalName, secretTemplate) if err := k8sClient.Create(ctx, binding); err != nil { return nil, err @@ -69,12 +70,12 @@ var _ = Describe("ServiceBinding controller", func() { return createdBinding, nil } - createBindingWithoutAssertions := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName string) (*v1.ServiceBinding, error) { - return createBindingWithoutAssertionsAndWait(ctx, name, namespace, instanceName, instanceNamespace, externalName, true) + createBindingWithoutAssertions := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName, secretTemplate string) (*v1.ServiceBinding, error) { + return createBindingWithoutAssertionsAndWait(ctx, name, namespace, instanceName, instanceNamespace, externalName, secretTemplate, true) } createBindingWithError := func(ctx context.Context, name, namespace, instanceName, externalName, failureMessage string) { - binding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, "", externalName) + binding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, "", externalName, "") if err != nil { Expect(err.Error()).To(ContainSubstring(failureMessage)) } else { @@ -83,7 +84,7 @@ var _ = Describe("ServiceBinding controller", func() { } createBindingWithBlockedError := func(ctx context.Context, name, namespace, instanceName, externalName, failureMessage string) *v1.ServiceBinding { - binding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, "", externalName) + binding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, "", externalName, "") if err != nil { Expect(err.Error()).To(ContainSubstring(failureMessage)) } else { @@ -92,8 +93,8 @@ var _ = Describe("ServiceBinding controller", func() { return binding } - createBinding := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName string) *v1.ServiceBinding { - createdBinding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, instanceNamespace, externalName) + createBinding := func(ctx context.Context, name, namespace, instanceName, instanceNamespace, externalName, secretTemplate string) *v1.ServiceBinding { + createdBinding, err := createBindingWithoutAssertions(ctx, name, namespace, instanceName, instanceNamespace, externalName, secretTemplate) Expect(err).ToNot(HaveOccurred()) Expect(createdBinding.Status.InstanceID).ToNot(BeEmpty()) Expect(createdBinding.Status.BindingID).To(Equal(fakeBindingID)) @@ -268,11 +269,86 @@ var _ = Describe("ServiceBinding controller", func() { }) }) }) + + It("should fail to create the secret if forbidden field is provided under spec.secretTemplate.metadata", func() { + ctx := context.Background() + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + metadata: + name: my-secret-name`) + binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", secretTemplate) + Expect(err).To(BeNil()) + bindingLookupKey := getResourceNamespacedName(binding) + Eventually(func() bool { + if err := k8sClient.Get(ctx, bindingLookupKey, binding); err != nil { + return false + } + cond := meta.FindStatusCondition(binding.GetConditions(), api.ConditionSucceeded) + return cond != nil && cond.Reason == "CreateFailed" && strings.Contains(cond.Message, "metadata field name is not allowed in generated secret manifest") + }, timeout*2, interval).Should(BeTrue()) + }) + + It("should fail to create the secret if wrong template key in the spec.secretTemplate is provided", func() { + ctx := context.Background() + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: {{ .non_existing_key | quote }}`) + + binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", secretTemplate) + Expect(err).To(BeNil()) + bindingLookupKey := getResourceNamespacedName(binding) + Eventually(func() bool { + if err := k8sClient.Get(ctx, bindingLookupKey, binding); err != nil { + return false + } + cond := meta.FindStatusCondition(binding.GetConditions(), api.ConditionSucceeded) + return cond != nil && cond.Reason == "CreateFailed" && strings.Contains(cond.Message, "map has no entry for key \"non_existing_key\"") + }, timeout*2, interval).Should(BeTrue()) + }) + + It("should fail to create the secret if secretTemplate is an unexpected type", func() { + ctx := context.Background() + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Pod`) + + binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", secretTemplate) + Expect(err).To(BeNil()) + bindingLookupKey := getResourceNamespacedName(binding) + Eventually(func() bool { + if err := k8sClient.Get(ctx, bindingLookupKey, binding); err != nil { + return false + } + cond := meta.FindStatusCondition(binding.GetConditions(), api.ConditionSucceeded) + return cond != nil && cond.Reason == "CreateFailed" && strings.Contains(cond.Message, "generated secret manifest has unexpected type") + }, timeout*2, interval).Should(BeTrue()) + }) + + It("should fail to create the secret if secretTemplate is invalid Yaml", func() { + ctx := context.Background() + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Pod`) + + binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", secretTemplate) + Expect(err).To(BeNil()) + bindingLookupKey := getResourceNamespacedName(binding) + Eventually(func() bool { + if err := k8sClient.Get(ctx, bindingLookupKey, binding); err != nil { + return false + } + cond := meta.FindStatusCondition(binding.GetConditions(), api.ConditionSucceeded) + return cond != nil && cond.Reason == "CreateFailed" && strings.Contains(cond.Message, "the generated secret manifest is not a valid YAML document") + }, timeout*2, interval).Should(BeTrue()) + }) }) Context("sync", func() { It("Should create binding and store the binding credentials in a secret", func() { - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "") Expect(createdBinding.Spec.ExternalName).To(Equal("binding-external-name")) Expect(createdBinding.Spec.UserInfo).NotTo(BeNil()) @@ -342,7 +418,7 @@ var _ = Describe("ServiceBinding controller", func() { When("secret deleted by user", func() { It("should recreate the secret", func() { - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "") secretLookupKey := types.NamespacedName{Name: createdBinding.Spec.SecretName, Namespace: createdBinding.Namespace} bindingSecret := getSecret(ctx, secretLookupKey.Name, secretLookupKey.Namespace, true) originalSecretUID := bindingSecret.UID @@ -399,7 +475,7 @@ var _ = Describe("ServiceBinding controller", func() { }) It("should eventually succeed", func() { - b, err := createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", true) + b, err := createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "", true) Expect(err).ToNot(HaveOccurred()) Expect(isResourceReady(b)).To(BeTrue()) }) @@ -432,6 +508,7 @@ var _ = Describe("ServiceBinding controller", func() { instanceName, "", "binding-external-name", + "", false, ) @@ -465,7 +542,7 @@ var _ = Describe("ServiceBinding controller", func() { }) It("creation will fail with appropriate message", func() { - createdBinding, _ = createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "") + createdBinding, _ = createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", "") waitForResourceCondition(ctx, createdBinding, api.ConditionFailed, metav1.ConditionTrue, "CreateFailed", "failed to create secret") }) }) @@ -480,7 +557,7 @@ var _ = Describe("ServiceBinding controller", func() { It("Should create binding and store the binding credentials in a secret", func() { fakeClient.StatusReturns(&smClientTypes.Operation{ResourceID: fakeBindingID, State: smClientTypes.SUCCEEDED}, nil) fakeClient.GetBindingByIDReturns(&smClientTypes.ServiceBinding{ID: fakeBindingID, Credentials: json.RawMessage("{\"secret_key\": \"secret_value\"}")}, nil) - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "", "") }) }) @@ -503,7 +580,7 @@ var _ = Describe("ServiceBinding controller", func() { fakeClient.GetBindingByIDReturns(&smClientTypes.ServiceBinding{ID: fakeBindingID, LastOperation: &smClientTypes.Operation{State: smClientTypes.SUCCEEDED, Type: smClientTypes.CREATE}}, nil) }) It("should eventually succeed", func() { - binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "") + binding, err := createBindingWithoutAssertions(ctx, bindingName, bindingTestNamespace, instanceName, "", "", "") Expect(err).ToNot(HaveOccurred()) waitForResourceCondition(ctx, binding, api.ConditionFailed, metav1.ConditionTrue, "", "no polling for you") fakeClient.ListBindingsReturns(&smClientTypes.ServiceBindings{ @@ -531,7 +608,7 @@ var _ = Describe("ServiceBinding controller", func() { api.UseInstanceMetadataNameInSecret: "true", } updateInstance(ctx, createdInstance) - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "", "") bindingSecret := getSecret(ctx, createdBinding.Spec.SecretName, createdBinding.Namespace, true) validateInstanceInfo(bindingSecret, instanceName) validateSecretMetadata(bindingSecret, nil) @@ -540,7 +617,7 @@ var _ = Describe("ServiceBinding controller", func() { When("external name is not provided", func() { It("succeeds and uses the k8s name as external name", func() { - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "", "") Expect(createdBinding.Spec.ExternalName).To(Equal(createdBinding.Name)) }) }) @@ -573,7 +650,7 @@ var _ = Describe("ServiceBinding controller", func() { createdInstance.Status.OperationType = smClientTypes.CREATE updateInstanceStatus(ctx, createdInstance) - createdBinding, err := createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", false) + createdBinding, err := createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "", false) Expect(err).ToNot(HaveOccurred()) Expect(isInProgress(createdBinding)).To(BeTrue()) @@ -588,7 +665,7 @@ var _ = Describe("ServiceBinding controller", func() { Context("Update", func() { BeforeEach(func() { - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "") Expect(isResourceReady(createdBinding)).To(BeTrue()) }) @@ -640,7 +717,7 @@ var _ = Describe("ServiceBinding controller", func() { } BeforeEach(func() { - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "") Expect(isResourceReady(createdBinding)).To(BeTrue()) }) @@ -818,7 +895,7 @@ var _ = Describe("ServiceBinding controller", func() { When(fmt.Sprintf("last operation is %s %s", testCase.lastOpType, testCase.lastOpState), func() { It("should resync status", func() { var err error - createdBinding, err = createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "fake-binding-external-name", false) + createdBinding, err = createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "fake-binding-external-name", "", false) Expect(err).ToNot(HaveOccurred()) smCallArgs := fakeClient.ListBindingsArgsForCall(0) Expect(smCallArgs.LabelQuery).To(HaveLen(1)) @@ -870,7 +947,7 @@ var _ = Describe("ServiceBinding controller", func() { It("should resync successfully", func() { var err error - createdBinding, err = createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "fake-binding-external-name", false) + createdBinding, err = createBindingWithoutAssertionsAndWait(ctx, bindingName, bindingTestNamespace, instanceName, "", "fake-binding-external-name", "", false) Expect(err).ToNot(HaveOccurred()) }) }) @@ -879,7 +956,7 @@ var _ = Describe("ServiceBinding controller", func() { Context("Credential Rotation", func() { BeforeEach(func() { fakeClient.RenameBindingReturns(nil, nil) - createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name") + createdBinding = createBinding(ctx, bindingName, bindingTestNamespace, instanceName, "", "binding-external-name", "") fakeClient.ListBindingsStub = func(params *sm.Parameters) (*smClientTypes.ServiceBindings, error) { if params == nil || params.FieldQuery == nil || len(params.FieldQuery) == 0 { return nil, nil @@ -1055,7 +1132,7 @@ var _ = Describe("ServiceBinding controller", func() { } }) It("should succeed", func() { - crossBinding = createBinding(ctx, bindingName, testNamespace, instanceName, bindingTestNamespace, "cross-binding-external-name") + crossBinding = createBinding(ctx, bindingName, testNamespace, instanceName, bindingTestNamespace, "cross-binding-external-name", "") By("Verify binding secret created") getSecret(ctx, createdBinding.Spec.SecretName, createdBinding.Namespace, true) @@ -1065,7 +1142,7 @@ var _ = Describe("ServiceBinding controller", func() { Context("cred rotation", func() { BeforeEach(func() { fakeClient.RenameBindingReturns(nil, nil) - crossBinding = createBinding(ctx, bindingName, testNamespace, instanceName, bindingTestNamespace, "cross-binding-external-name") + crossBinding = createBinding(ctx, bindingName, testNamespace, instanceName, bindingTestNamespace, "cross-binding-external-name", "") fakeClient.ListBindingsStub = func(params *sm.Parameters) (*smClientTypes.ServiceBindings, error) { if params == nil || params.FieldQuery == nil || len(params.FieldQuery) == 0 { return nil, nil @@ -1140,7 +1217,7 @@ var _ = Describe("ServiceBinding controller", func() { }) }) -func generateBasicBindingTemplate(name, namespace, instanceName, instanceNamespace, externalName string) *v1.ServiceBinding { +func generateBasicBindingTemplate(name, namespace, instanceName, instanceNamespace, externalName, secretTemplate string) *v1.ServiceBinding { binding := newBindingObject(name, namespace) binding.Spec.ServiceInstanceName = instanceName if len(instanceNamespace) > 0 { @@ -1158,6 +1235,7 @@ func generateBasicBindingTemplate(name, namespace, instanceName, instanceNamespa }, }, } + binding.Spec.SecretTemplate = secretTemplate return binding } diff --git a/go.mod b/go.mod index e141a33a..d1877593 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,16 @@ module github.com/SAP/sap-btp-service-operator go 1.20 require ( + github.com/Masterminds/sprig/v3 v3.2.3 github.com/go-logr/logr v1.2.4 + github.com/golang/mock v1.1.1 github.com/google/uuid v1.3.0 github.com/kelseyhightower/envconfig v1.4.0 + github.com/lithammer/dedent v1.1.0 github.com/onsi/ginkgo v1.16.5 + github.com/onsi/ginkgo/v2 v2.11.0 github.com/onsi/gomega v1.27.10 + github.com/pkg/errors v0.9.1 golang.org/x/oauth2 v0.12.0 k8s.io/api v0.27.3 k8s.io/apimachinery v0.27.3 @@ -18,6 +23,8 @@ require ( ) require ( + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -28,31 +35,38 @@ require ( github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.1 // indirect github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.1.0 // indirect + github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect + github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nxadm/tail v1.4.8 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.17.0 // indirect github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.13.0 // indirect diff --git a/go.sum b/go.sum index 2652675c..71132eb7 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -36,11 +45,13 @@ github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/ github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -69,9 +80,15 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -91,10 +108,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -110,6 +133,7 @@ github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= +github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= @@ -130,14 +154,20 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -147,6 +177,7 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= @@ -159,6 +190,10 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -167,6 +202,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -178,7 +215,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -191,6 +231,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -198,21 +239,30 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= @@ -227,6 +277,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/ioutils/error_conv_writer.go b/internal/ioutils/error_conv_writer.go new file mode 100644 index 00000000..3c23ecfa --- /dev/null +++ b/internal/ioutils/error_conv_writer.go @@ -0,0 +1,22 @@ +package ioutils + +import "io" + +// An ErrorConversionWriter delegates all writes to W and allows to convert +// errors returned by W using the Converter function. Converter is called for +// every non-nil error returned by W. Converter should not return nil, as this +// may violate the spec of Writer (returning non-zero bytes written without +// error). +type ErrorConversionWriter struct { + W io.Writer // underlying Writer + Converter func(err error) error // converter function +} + +// Write implements io.Writer +func (w *ErrorConversionWriter) Write(p []byte) (int, error) { + written, err := w.W.Write(p) + if err != nil { + err = w.Converter(err) + } + return written, err +} diff --git a/internal/ioutils/error_conv_writer_test.go b/internal/ioutils/error_conv_writer_test.go new file mode 100644 index 00000000..7aeca824 --- /dev/null +++ b/internal/ioutils/error_conv_writer_test.go @@ -0,0 +1,91 @@ +package ioutils_test + +import ( + "errors" + + "github.com/SAP/sap-btp-service-operator/internal/ioutils" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ErrorConversionWriter", func() { + + var SUT *ioutils.ErrorConversionWriter + + var W *MockWriter // the SUT.W + + BeforeEach(func() { + mockCtrl := gomock.NewController(GinkgoT()) + DeferCleanup(mockCtrl.Finish) + + W = NewMockWriter(mockCtrl) + }) + + JustBeforeEach(func() { + SUT = &ioutils.ErrorConversionWriter{W: W} // Converter not set yet + }) + + It("panicks if Converter is nil", func() { + + Expect(SUT.Converter).To(BeNil()) + + input := []byte("A") + + W.EXPECT(). + Write(input). + Return(0, errors.New("error1")) + + exercise := func() { + // EXERCISE + SUT.Write(input) + } + + // VERIFY + Expect(exercise).To(Panic()) + }) + + It("calls Converter if W returns error", func() { + input := []byte("A") + bytesWritterFromW := 123 + errorFromW := errors.New("errorFromW") + errorFromConverter := errors.New("errorFromConverter") + + W.EXPECT(). + Write(input). + Return(bytesWritterFromW, errorFromW) + + SUT.Converter = func(err error) error { + Expect(err).To(BeIdenticalTo(errorFromW)) + return errorFromConverter + } + + // EXERCISE + bytesWritten, err := SUT.Write(input) + + // VERIFY + Expect(err).To(BeIdenticalTo(errorFromConverter)) + Expect(bytesWritten).To(Equal(bytesWritterFromW)) + }) + + It("does not call Converter if W returns no error", func() { + input := []byte("A") + bytesWritterFromW := 9991 + + W.EXPECT(). + Write(input). + Return(bytesWritterFromW, nil) + + SUT.Converter = func(err error) error { + Fail("Converter called unexpectedly") + return err + } + + // EXERCISE + bytesWritten, err := SUT.Write(input) + + // VERIFY + Expect(err).ShouldNot(HaveOccurred()) + Expect(bytesWritten).To(Equal(bytesWritterFromW)) + }) +}) diff --git a/internal/ioutils/go_generate_for_test.go b/internal/ioutils/go_generate_for_test.go new file mode 100644 index 00000000..02a6220e --- /dev/null +++ b/internal/ioutils/go_generate_for_test.go @@ -0,0 +1,3 @@ +package ioutils_test + +//go:generate mockgen -package ioutils_test -self_package github.com/SAP/sap-btp-service-operator/internal/ioutils_test -destination io_mocks_for_test.go io Writer diff --git a/internal/ioutils/io_mocks_for_test.go b/internal/ioutils/io_mocks_for_test.go new file mode 100644 index 00000000..0a4dc7ab --- /dev/null +++ b/internal/ioutils/io_mocks_for_test.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: io (interfaces: Writer) + +// Package ioutils_test is a generated GoMock package. +package ioutils_test + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockWriter is a mock of Writer interface. +type MockWriter struct { + ctrl *gomock.Controller + recorder *MockWriterMockRecorder +} + +// MockWriterMockRecorder is the mock recorder for MockWriter. +type MockWriterMockRecorder struct { + mock *MockWriter +} + +// NewMockWriter creates a new mock instance. +func NewMockWriter(ctrl *gomock.Controller) *MockWriter { + mock := &MockWriter{ctrl: ctrl} + mock.recorder = &MockWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWriter) EXPECT() *MockWriterMockRecorder { + return m.recorder +} + +// Write mocks base method. +func (m *MockWriter) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockWriterMockRecorder) Write(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockWriter)(nil).Write), arg0) +} diff --git a/internal/ioutils/ioutils_suite_test.go b/internal/ioutils/ioutils_suite_test.go new file mode 100644 index 00000000..9391ff1a --- /dev/null +++ b/internal/ioutils/ioutils_suite_test.go @@ -0,0 +1,13 @@ +package ioutils + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIOUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "IOUtils") +} diff --git a/internal/ioutils/limited_writer.go b/internal/ioutils/limited_writer.go new file mode 100644 index 00000000..dbc78a5b --- /dev/null +++ b/internal/ioutils/limited_writer.go @@ -0,0 +1,52 @@ +package ioutils + +import ( + "errors" + "io" +) + +var ( + // ErrLimitExceeded is the error returned when a limit was exceeded. + // Callers will test for ErrLimitExceeded using ==. + ErrLimitExceeded = errors.New("limit exceeded") +) + +// A LimitedWriter writes to W but limits the amount of data that can be written +// to just N bytes. Each call to Write immediately returns ErrLimitExceeded if N +// <= 0. Otherwise Write writes max N bytes to W and updates N to reflect the +// new amount remaining. If the number of byte to be written is greater than N, +// ErrLimitExceeded is returned. Any error from W is returned to the caller of +// Write, i.e. they have precedence over ErrLimitExceeded. +type LimitedWriter struct { + W io.Writer // underlying writer + N int64 // max bytes remaining +} + +// Write implements io.Writer +func (l *LimitedWriter) Write(p []byte) (int, error) { + if l.N <= 0 { + return 0, ErrLimitExceeded + } + + writeable := int64(len(p)) + if l.N < writeable { + writeable = l.N + } + + written, err := l.W.Write(p[:writeable]) + if written < 0 { + written = 0 + } + l.N -= int64(written) + if err != nil { + return written, err + } + if written < int(writeable) { + return written, io.ErrShortWrite + } + + if writeable < int64(len(p)) { + err = ErrLimitExceeded + } + return written, err +} diff --git a/internal/ioutils/limited_writer_test.go b/internal/ioutils/limited_writer_test.go new file mode 100644 index 00000000..46490d6f --- /dev/null +++ b/internal/ioutils/limited_writer_test.go @@ -0,0 +1,805 @@ +package ioutils_test + +import ( + "errors" + "fmt" + "io" + + "github.com/SAP/sap-btp-service-operator/internal/ioutils" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LimitedWriter", func() { + + var SUT *ioutils.LimitedWriter + + var W *MockWriter // the SUT.W + var N int64 // the initial SUT.N + + BeforeEach(func() { + mockCtrl := gomock.NewController(GinkgoT()) + DeferCleanup(mockCtrl.Finish) + + W = NewMockWriter(mockCtrl) + N = 0 + }) + + JustBeforeEach(func() { + SUT = &ioutils.LimitedWriter{W: W, N: N} + }) + + { + reuseSpec := func() { + DescribeTable( + "Write", + + Entry("nil slice", nil), + Entry("zero byte slice", []byte("")), + Entry("one byte slice", []byte("1")), + Entry("3 bytes slice", []byte("123")), + + func(p []byte) { + // W not called + + // EXERCISE + written, err := SUT.Write(p) + + // VERIFY + Expect(written).To(Equal(0)) + Expect(err).To(BeIdenticalTo(ioutils.ErrLimitExceeded)) + }, + ) + } + + When("N == -1", func() { + + BeforeEach(func() { + N = -1 + }) + + reuseSpec() + }) + + When("N == 0", func() { + + BeforeEach(func() { + N = -1 + }) + + reuseSpec() + }) + } + + customError1 := errors.New("customError1") + + type TestCase struct { + p []byte + expectWCalledWith []byte + bytesWrittenByW int + errorFromW error + wantBytesWritten int + wantError error + wantN int64 + } + + testFunc := func(tc TestCase) { + Expect(SUT.W).To(BeIdenticalTo(W)) + + W.EXPECT(). + Write(tc.expectWCalledWith). + Return(tc.bytesWrittenByW, tc.errorFromW) + + // EXERCISE + written, err := SUT.Write(tc.p) + + // VERIFY + Expect(written).To(Equal(tc.wantBytesWritten)) + if tc.wantError != nil { + Expect(err).To(BeIdenticalTo(tc.wantError)) + } else { + Expect(err).ShouldNot(HaveOccurred()) + } + Expect(SUT.N).To(Equal(tc.wantN)) + Expect(SUT.W).To(BeIdenticalTo(W)) + } + + entryDescriptionFunc := func(tc TestCase) string { + return fmt.Sprintf( + "when W.Write(p) == (%v, %v)", + tc.bytesWrittenByW, tc.errorFromW, + ) + } + + When("N == 1", func() { + + BeforeEach(func() { + N = 1 + }) + + // When len(p) == 0 + { + reuseEntries := []TableEntry{ + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: nil, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + + // W writes 1 byte (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: nil, + wantN: 0, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 0, + }), + + // W writes 2 byte (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: nil, + wantBytesWritten: 2, + wantError: nil, + wantN: -1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: customError1, + wantBytesWritten: 2, + wantError: customError1, + wantN: -1, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: nil, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + } + + When("p == ", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = nil + tc.expectWCalledWith = nil + testFunc(tc) + }, + + entryDescriptionFunc, + reuseEntries, + ) + }) + + When("p == []byte{}", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte{} + tc.expectWCalledWith = []byte{} + testFunc(tc) + }, + + entryDescriptionFunc, + reuseEntries, + ) + }) + } + + When("len(p) == 1", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte("1") + tc.expectWCalledWith = []byte("1") + testFunc(tc) + }, + + entryDescriptionFunc, + + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + + // W writes 1 byte + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: nil, + wantN: 0, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 0, + }), + + // W writes 2 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: nil, + wantBytesWritten: 2, + wantError: nil, + wantN: -1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: customError1, + wantBytesWritten: 2, + wantError: customError1, + wantN: -1, + }), + + // W writes 100 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: nil, + wantBytesWritten: 100, + wantError: nil, + wantN: -99, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: customError1, + wantBytesWritten: 100, + wantError: customError1, + wantN: -99, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + ) + }) + + When("len(p) == 10", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte("1234567890") + tc.expectWCalledWith = []byte("1") // 1 byte + testFunc(tc) + }, + + entryDescriptionFunc, + + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + + // W writes 1 byte + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: ioutils.ErrLimitExceeded, + wantN: 0, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 0, + }), + + // W writes 2 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: nil, + wantBytesWritten: 2, + wantError: ioutils.ErrLimitExceeded, + wantN: -1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: customError1, + wantBytesWritten: 2, + wantError: customError1, + wantN: -1, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 1, + }), + ) + }) + }) + + When("N == 10", func() { + + BeforeEach(func() { + N = 10 + }) + + // When len(p) == 0 + { + reuseEntries := []TableEntry{ + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: nil, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + + // W writes 1 byte (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: nil, + wantN: 9, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 9, + }), + + // W writes 11 byte (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 11, + errorFromW: nil, + wantBytesWritten: 11, + wantError: nil, + wantN: -1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 11, + errorFromW: customError1, + wantBytesWritten: 11, + wantError: customError1, + wantN: -1, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: nil, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + } + + When("p == ", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = nil + tc.expectWCalledWith = nil + testFunc(tc) + }, + + entryDescriptionFunc, + reuseEntries, + ) + }) + + When("p == []byte{}", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte{} + tc.expectWCalledWith = []byte{} + testFunc(tc) + }, + + entryDescriptionFunc, + reuseEntries, + ) + }) + } + + When("len(p) == 1", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte("1") + tc.expectWCalledWith = []byte("1") + testFunc(tc) + }, + + entryDescriptionFunc, + + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + + // W writes 1 byte + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: nil, + wantN: 9, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 9, + }), + + // W writes 2 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: nil, + wantBytesWritten: 2, + wantError: nil, + wantN: 8, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 2, + errorFromW: customError1, + wantBytesWritten: 2, + wantError: customError1, + wantN: 8, + }), + + // W writes 100 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: nil, + wantBytesWritten: 100, + wantError: nil, + wantN: -90, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: customError1, + wantBytesWritten: 100, + wantError: customError1, + wantN: -90, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + ) + }) + + When("len(p) == 10", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte("1234567890") + tc.expectWCalledWith = []byte("1234567890") + testFunc(tc) + }, + + entryDescriptionFunc, + + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + + // W writes 1 byte + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: nil, + wantBytesWritten: 1, + wantError: io.ErrShortWrite, + wantN: 9, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 1, + errorFromW: customError1, + wantBytesWritten: 1, + wantError: customError1, + wantN: 9, + }), + + // W writes 9 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 9, + errorFromW: nil, + wantBytesWritten: 9, + wantError: io.ErrShortWrite, + wantN: 1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 9, + errorFromW: customError1, + wantBytesWritten: 9, + wantError: customError1, + wantN: 1, + }), + + // W writes 10 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 10, + errorFromW: nil, + wantBytesWritten: 10, + wantError: nil, + wantN: 0, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 10, + errorFromW: customError1, + wantBytesWritten: 10, + wantError: customError1, + wantN: 0, + }), + + // W writes 100 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: nil, + wantBytesWritten: 100, + wantError: nil, + wantN: -90, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: customError1, + wantBytesWritten: 100, + wantError: customError1, + wantN: -90, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + ) + }) + + When("len(p) == 11", func() { + + DescribeTable( + "Write", + + func(tc TestCase) { + tc.p = []byte("12345678901") // 11 bytes + tc.expectWCalledWith = []byte("1234567890") // 10 (== N) bytes + testFunc(tc) + }, + + entryDescriptionFunc, + + // W writes 0 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 0, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + + // W writes 10 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 10, + errorFromW: nil, + wantBytesWritten: 10, + wantError: ioutils.ErrLimitExceeded, + wantN: 0, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 10, + errorFromW: customError1, + wantBytesWritten: 10, + wantError: customError1, + wantN: 0, + }), + + // W writes 11 bytes + Entry(nil, TestCase{ + bytesWrittenByW: 11, + errorFromW: nil, + wantBytesWritten: 11, + wantError: ioutils.ErrLimitExceeded, + wantN: -1, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 11, + errorFromW: customError1, + wantBytesWritten: 11, + wantError: customError1, + wantN: -1, + }), + + // W writes 100 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: nil, + wantBytesWritten: 100, + wantError: ioutils.ErrLimitExceeded, + wantN: -90, + }), + Entry(nil, TestCase{ + bytesWrittenByW: 100, + errorFromW: customError1, + wantBytesWritten: 100, + wantError: customError1, + wantN: -90, + }), + + // W writes -1 bytes (off-spec) + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: nil, + wantBytesWritten: 0, + wantError: io.ErrShortWrite, + wantN: 10, + }), + Entry(nil, TestCase{ + bytesWrittenByW: -1, + errorFromW: customError1, + wantBytesWritten: 0, + wantError: customError1, + wantN: 10, + }), + ) + }) + }) +}) diff --git a/internal/secrets/template/allowed_sprig_funcs.go b/internal/secrets/template/allowed_sprig_funcs.go new file mode 100644 index 00000000..cd59bd67 --- /dev/null +++ b/internal/secrets/template/allowed_sprig_funcs.go @@ -0,0 +1,262 @@ +package template + +// genericMap from https://github.com/Masterminds/sprig/blob/master/functions.go +// comment out all unwanted functions +var allowedSprigFunctions = map[string]interface{}{ + "hello": nil, + + // Date functions + "ago": nil, + "date": nil, + "date_in_zone": nil, + "date_modify": nil, + "dateInZone": nil, + "dateModify": nil, + "duration": nil, + "durationRound": nil, + "htmlDate": nil, + "htmlDateInZone": nil, + "must_date_modify": nil, + "mustDateModify": nil, + "mustToDate": nil, + "now": nil, + "toDate": nil, + "unixEpoch": nil, + + // Strings + "abbrev": nil, + "abbrevboth": nil, + "trunc": nil, + "trim": nil, + "upper": nil, + "lower": nil, + "title": nil, + "untitle": nil, + "substr": nil, + // Switch order so that "foo" | repeat 5 + "repeat": nil, + // Deprecated: Use trimAll. + //"trimall": nil, + // Switch order so that "$foo" | trimall "$" + "trimAll": nil, + "trimSuffix": nil, + "trimPrefix": nil, + "nospace": nil, + "initials": nil, + "randAlphaNum": nil, + "randAlpha": nil, + "randAscii": nil, + "randNumeric": nil, + "swapcase": nil, + "shuffle": nil, + "snakecase": nil, + "camelcase": nil, + "kebabcase": nil, + "wrap": nil, + "wrapWith": nil, + // Switch order so that "foobar" | contains "foo" + "contains": nil, + "hasPrefix": nil, + "hasSuffix": nil, + "quote": nil, + "squote": nil, + "cat": nil, + "indent": nil, + "nindent": nil, + "replace": nil, + "plural": nil, + "sha1sum": nil, + "sha256sum": nil, + "adler32sum": nil, + "toString": nil, + + // Wrap Atoi to stop errors. + "atoi": nil, + "int64": nil, + "int": nil, + "float64": nil, + "seq": nil, + "toDecimal": nil, + + // split "/" foo/bar returns map[int]string{0: foo, 1: bar} + "split": nil, + "splitList": nil, + // splitn "/" foo/bar/fuu returns map[int]string{0: foo, 1: bar/fuu} + "splitn": nil, + "toStrings": nil, + + "until": nil, + "untilStep": nil, + + // VERY basic arithmetic. + "add1": nil, + "add": nil, + "sub": nil, + "div": nil, + "mod": nil, + "mul": nil, + "randInt": nil, + "add1f": nil, + "addf": nil, + "subf": nil, + "divf": nil, + "mulf": nil, + "biggest": nil, + "max": nil, + "min": nil, + "maxf": nil, + "minf": nil, + "ceil": nil, + "floor": nil, + "round": nil, + + // string slices. Note that we reverse the order b/c that's better + // for template processing. + "join": nil, + "sortAlpha": nil, + + // Defaults + "default": nil, + "empty": nil, + "coalesce": nil, + "all": nil, + "any": nil, + "compact": nil, + "mustCompact": nil, + "fromJson": nil, + "toJson": nil, + "toPrettyJson": nil, + "toRawJson": nil, + "mustFromJson": nil, + "mustToJson": nil, + "mustToPrettyJson": nil, + "mustToRawJson": nil, + "ternary": nil, + "deepCopy": nil, + "mustDeepCopy": nil, + + // Reflection + "typeOf": nil, + "typeIs": nil, + "typeIsLike": nil, + "kindOf": nil, + "kindIs": nil, + "deepEqual": nil, + + // OS: + // "env": nil, + // "expandenv": nil, + + // Network: + // "getHostByName": nil, + + // Paths: + "base": nil, + "dir": nil, + "clean": nil, + "ext": nil, + "isAbs": nil, + + // Filepaths: + // "osBase": nil, + // "osClean": nil, + // "osDir": nil, + // "osExt": nil, + // "osIsAbs": nil, + + // Encoding: + "b64enc": nil, + "b64dec": nil, + "b32enc": nil, + "b32dec": nil, + + // Data Structures: + "tuple": nil, // FIXME: with the addition of append/prepend these are no longer immutable. + "list": nil, + "dict": nil, + "get": nil, + "set": nil, + "unset": nil, + "hasKey": nil, + "pluck": nil, + "keys": nil, + "pick": nil, + "omit": nil, + "merge": nil, + "mergeOverwrite": nil, + "mustMerge": nil, + "mustMergeOverwrite": nil, + "values": nil, + + "append": nil, "push": nil, + "mustAppend": nil, "mustPush": nil, + "prepend": nil, + "mustPrepend": nil, + "first": nil, + "mustFirst": nil, + "rest": nil, + "mustRest": nil, + "last": nil, + "mustLast": nil, + "initial": nil, + "mustInitial": nil, + "reverse": nil, + "mustReverse": nil, + "uniq": nil, + "mustUniq": nil, + "without": nil, + "mustWithout": nil, + "has": nil, + "mustHas": nil, + "slice": nil, + "mustSlice": nil, + "concat": nil, + "dig": nil, + "chunk": nil, + "mustChunk": nil, + + // Crypto: + // "bcrypt": nil, + // "htpasswd": nil, + // "genPrivateKey": nil, + // "derivePassword": nil, + // "buildCustomCert": nil, + // "genCA": nil, + // "genCAWithKey": nil, + // "genSelfSignedCert": nil, + // "genSelfSignedCertWithKey": nil, + // "genSignedCert": nil, + // "genSignedCertWithKey": nil, + // "encryptAES": nil, + // "decryptAES": nil, + // "randBytes": nil, + + // UUIDs: + "uuidv4": nil, + + // SemVer: + "semver": nil, + "semverCompare": nil, + + // Flow Control: + "fail": nil, + + // Regex + "regexMatch": nil, + "mustRegexMatch": nil, + "regexFindAll": nil, + "mustRegexFindAll": nil, + "regexFind": nil, + "mustRegexFind": nil, + "regexReplaceAll": nil, + "mustRegexReplaceAll": nil, + "regexReplaceAllLiteral": nil, + "mustRegexReplaceAllLiteral": nil, + "regexSplit": nil, + "mustRegexSplit": nil, + "regexQuoteMeta": nil, + + // URLs: + "urlParse": nil, + "urlJoin": nil, +} diff --git a/internal/secrets/template/template.go b/internal/secrets/template/template.go new file mode 100644 index 00000000..b2c551ac --- /dev/null +++ b/internal/secrets/template/template.go @@ -0,0 +1,116 @@ +package template + +import ( + "fmt" + "io" + "strings" + "text/template" + + sprig "github.com/Masterminds/sprig/v3" + "github.com/SAP/sap-btp-service-operator/internal/ioutils" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" +) + +const templateOutputMaxBytes int64 = 1 * 1024 * 1024 + +var validGroupVersionKind = schema.GroupVersionKind{ + Group: "", + Kind: "Secret", + Version: "v1", +} + +// CreateSecretFromTemplate executes the template to create a secret objects, validates and returns it +// The template needs to be a v1 Secret and in metadata labels and annotations are allowed only +// Set templateOptions of the "text/template" package to specify the template behavior +func CreateSecretFromTemplate(templateName, secretTemplate string, data map[string]interface{}) (*corev1.Secret, error) { + + secretManifest, err := executeTemplate(templateName, secretTemplate, data) + if err != nil { + return nil, errors.Wrap(err, "could not execute template") + } + + yamlSerializer := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + o, err := runtime.Decode(yamlSerializer, []byte(secretManifest)) + if err != nil { + return nil, errors.Wrapf(err, "the generated secret manifest is not a valid YAML document: %q", secretManifest) + } + + obj := o.(*unstructured.Unstructured) + + // validate metadata + allowedMetadataFields := map[string]string{"labels": "any", "annotations": "any"} + + metadataKeyValues, _, err := unstructured.NestedMap(obj.Object, "metadata") + if err != nil { + return nil, errors.Wrap(err, "failed to read metadata fields of generated secret manifest") + } + + for metadataKey := range metadataKeyValues { + if _, ok := allowedMetadataFields[metadataKey]; !ok { + return nil, fmt.Errorf("metadata field %s is not allowed in generated secret manifest", metadataKey) + } + } + + // validate GroupVersionKind + gvk := obj.GetObjectKind().GroupVersionKind() + if gvk != validGroupVersionKind { + return nil, fmt.Errorf("generated secret manifest has unexpected type: %q", gvk.String()) + } + + var secret *corev1.Secret + err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &secret) + if err != nil { + return nil, errors.Wrap(err, "the generated secret manifest is not valid") + } + return secret, nil +} + +// ParseTemplate create a new template with given name, add allowed sprig functions and parse the template +func ParseTemplate(templateName, text string) (*template.Template, error) { + return template.New(templateName).Funcs(filteredFuncMap()).Parse(text) +} + +func filteredFuncMap() template.FuncMap { + r := sprig.TxtFuncMap() + + for sprigFunc := range r { + if _, ok := allowedSprigFunctions[sprigFunc]; !ok { + delete(r, sprigFunc) + } + } + return r +} + +func executeTemplate(templateName, text string, parameters map[string]interface{}) (string, error) { + t, err := ParseTemplate(templateName, text) + if err != nil { + return "", err + } + + var stringBuilder strings.Builder + var writer io.Writer = &ioutils.LimitedWriter{ + W: &stringBuilder, + N: templateOutputMaxBytes, + } + writer = &ioutils.ErrorConversionWriter{ + W: writer, + Converter: func(err error) error { + if err == ioutils.ErrLimitExceeded { + return fmt.Errorf("the size of the generated secret manifest exceeds the limit of %d bytes", templateOutputMaxBytes) + } + return err + }, + } + + err = t.Option("missingkey=error").Execute(writer, parameters) + if err != nil { + return "", err + } + + return stringBuilder.String(), nil +} diff --git a/internal/secrets/template/template_suite_test.go b/internal/secrets/template/template_suite_test.go new file mode 100644 index 00000000..74f53893 --- /dev/null +++ b/internal/secrets/template/template_suite_test.go @@ -0,0 +1,13 @@ +package template + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Secret Template Suite") +} diff --git a/internal/secrets/template/template_test.go b/internal/secrets/template/template_test.go new file mode 100644 index 00000000..f413a9a0 --- /dev/null +++ b/internal/secrets/template/template_test.go @@ -0,0 +1,171 @@ +package template + +import ( + "fmt" + "strings" + + "github.com/lithammer/dedent" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ExecuteTemplate and replace dynamic values", func() { + + Describe("CreateSecretFromTemplate", func() { + + Context("With valid secretTemplate, but missing keys (credentials are nil)", func() { + + It("should fail", func() { + nonexistingKey := "nonexistingKey" + secretTemplate := fmt.Sprintf( + dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: {{ .%s }} + `), + nonexistingKey, + ) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).Should(MatchError(ContainSubstring("map has no entry for key \"%s\"", nonexistingKey))) + Expect(secret).Should(BeNil()) + }) + }) + + Context("With unknown field", func() { + + It("should succeed and invalid key provided in the secret is ignored", func() { + expectedSecret := &corev1.Secret{ + TypeMeta: v1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + } + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + unknownField: foo + `) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(secret).Should(Equal(expectedSecret)) + }) + }) + + Context("With wrong kind", func() { + + It("should fail", func() { + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Pod + `) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).Should(MatchError( + SatisfyAll( + ContainSubstring("generated secret manifest has unexpected type"), + ContainSubstring("Pod"), + ), + )) + Expect(secret).Should(BeNil()) + }) + }) + + Context("With sprig functions", func() { + + It("should succeed using quotes", func() { + param1Value := "value1" + data := map[string]interface{}{ + "param1": param1Value, + } + expectedSecret := &corev1.Secret{ + TypeMeta: v1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + StringData: map[string]string{ + "foo": param1Value}, + } + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: {{ .param1 | quote }} + `) + + secret, err := CreateSecretFromTemplate("", secretTemplate, data) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(secret).Should(Equal(expectedSecret)) + }) + + It("should fail if forbidden sprig func is used in the template", func() { + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: {{ .param1 | env }} + `) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).Should(MatchError(ContainSubstring("function \"env\" not defined"))) + Expect(secret).To(BeNil()) + }) + + It("should not panic with failing mustToDate function", func() { + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: {{ mustToDate "2006-01-02" "not a date string" }} + `) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).Should(MatchError( + SatisfyAll( + ContainSubstring("mustToDate"), + ContainSubstring("not a date string"), + ), + )) + Expect(secret).To(BeNil()) + }) + }) + + Describe("limited template output size", func() { + + It("should succeed if template output is too big", func() { + secretTemplate := dedent.Dedent(` + apiVersion: v1 + kind: Secret + stringData: + foo: x + `) + secretTemplate += strings.Repeat("#", int(templateOutputMaxBytes)-len(secretTemplate)) + Expect(len(secretTemplate)).To(Equal(int(templateOutputMaxBytes))) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(secret).NotTo(BeNil()) + }) + + It("should fail if template output is too big", func() { + secretTemplate := strings.Repeat("a", int(templateOutputMaxBytes)+1) + + secret, err := CreateSecretFromTemplate("", secretTemplate, nil) + + Expect(err).Should(MatchError(ContainSubstring("the size of the generated secret manifest exceeds the limit"))) + Expect(secret).To(BeNil()) + }) + }) + }) +}) diff --git a/sapbtp-operator-charts/templates/crd.yml b/sapbtp-operator-charts/templates/crd.yml index 6f79e1f4..30c03429 100644 --- a/sapbtp-operator-charts/templates/crd.yml +++ b/sapbtp-operator-charts/templates/crd.yml @@ -124,6 +124,15 @@ spec: and additional info under single key. Convenient way to store whole binding data in single file when using `volumeMounts`. type: string + secretTemplate: + description: A Go template that generates a custom Kubernetes v1/Secret + based on the data of the service binding returned by Service Manager. + The generated secret is used instead of the default secret. + This is useful if the consumer of service binding data expects them in + a specific format. + + For Go templates see https://pkg.go.dev/text/template. + type: string serviceInstanceName: description: The k8s name of the service instance to bind, should be in the namespace of the binding From e449ea1e07240a2dd7c2d3c24ef3c563a064953b Mon Sep 17 00:00:00 2001 From: Matthias Rinck <56343232+rinckm@users.noreply.github.com> Date: Fri, 1 Dec 2023 10:03:48 +0100 Subject: [PATCH 2/4] Update gomock version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d1877593..03afe38e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/Masterminds/sprig/v3 v3.2.3 github.com/go-logr/logr v1.2.4 - github.com/golang/mock v1.1.1 + github.com/golang/mock v1.2.0 github.com/google/uuid v1.3.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/lithammer/dedent v1.1.0 From be20fa82088598cf893d0c51403533b3f2e89b34 Mon Sep 17 00:00:00 2001 From: Matthias Rinck Date: Fri, 1 Dec 2023 10:09:40 +0100 Subject: [PATCH 3/4] go mod tidy --- go.sum | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go.sum b/go.sum index 71132eb7..d614fd87 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,9 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= From 0ca2d687ccd7812a4b651bc80d7dc1c15b889e44 Mon Sep 17 00:00:00 2001 From: Matthias Rinck Date: Fri, 1 Dec 2023 13:16:15 +0100 Subject: [PATCH 4/4] update go version --- .github/workflows/go.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cc8fafb7..93827409 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.20.0 + - name: Set up Go 1.21.4 uses: actions/setup-go@v2 with: - go-version: 1.20.0 + go-version: 1.21.4 - name: Check out code into the Go module directory uses: actions/checkout@v2 @@ -31,10 +31,10 @@ jobs: - name: Test run: make test - + - name: Lint run: make lint - + - name: Send coverage uses: shogo82148/actions-goveralls@v1 with: