diff --git a/clients/cfrestclient/cloud_foundry_operations_extended.go b/clients/cfrestclient/cloud_foundry_operations_extended.go index e06da53..f65e9ed 100644 --- a/clients/cfrestclient/cloud_foundry_operations_extended.go +++ b/clients/cfrestclient/cloud_foundry_operations_extended.go @@ -10,4 +10,6 @@ type CloudFoundryOperationsExtended interface { GetApplicationRoutes(appGuid string) ([]models.ApplicationRoute, error) GetServiceInstances(mtaId, mtaNamespace, spaceGuid string) ([]models.CloudFoundryServiceInstance, error) GetServiceBindings(serviceName string) ([]models.ServiceBinding, error) + GetServiceInstanceByName(serviceName, spaceGuid string) (models.CloudFoundryServiceInstance, error) + CreateUserProvidedServiceInstance(serviceName string, spaceGuid string, credentials map[string]string) (models.CloudFoundryServiceInstance, error) } diff --git a/clients/cfrestclient/fakes/fake_cloud_foundry_client.go b/clients/cfrestclient/fakes/fake_cloud_foundry_client.go index ddabf80..783c3a1 100644 --- a/clients/cfrestclient/fakes/fake_cloud_foundry_client.go +++ b/clients/cfrestclient/fakes/fake_cloud_foundry_client.go @@ -34,3 +34,11 @@ func (f FakeCloudFoundryClient) GetServiceInstances(mtaId, mtaNamespace, spaceGu func (f FakeCloudFoundryClient) GetServiceBindings(serviceName string) ([]models.ServiceBinding, error) { return f.ServiceBindings, f.ServiceBindingsErr } + +func (f FakeCloudFoundryClient) GetServiceInstanceByName(serviceName, spaceGuid string) (models.CloudFoundryServiceInstance, error) { + return f.Services[0], f.ServiceBindingsErr +} + +func (f FakeCloudFoundryClient) CreateUserProvidedServiceInstance(serviceName string, spaceGuid string, credentials map[string]string) (models.CloudFoundryServiceInstance, error) { + return f.Services[0], f.ServicesErr +} diff --git a/clients/cfrestclient/resilient/resilient_rest_cloud_foundry_client_extended.go b/clients/cfrestclient/resilient/resilient_rest_cloud_foundry_client_extended.go index b2523d5..8d5828c 100644 --- a/clients/cfrestclient/resilient/resilient_rest_cloud_foundry_client_extended.go +++ b/clients/cfrestclient/resilient/resilient_rest_cloud_foundry_client_extended.go @@ -1,9 +1,10 @@ package resilient import ( + "time" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/cfrestclient" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/models" - "time" ) type ResilientCloudFoundryRestClient struct { @@ -46,6 +47,18 @@ func (c ResilientCloudFoundryRestClient) GetServiceBindings(serviceName string) }, c.MaxRetriesCount, c.RetryInterval) } +func (c ResilientCloudFoundryRestClient) GetServiceInstanceByName(serviceName, spaceGuid string) (models.CloudFoundryServiceInstance, error) { + return retryOnError(func() (models.CloudFoundryServiceInstance, error) { + return c.CloudFoundryRestClient.GetServiceInstanceByName(serviceName, spaceGuid) + }, c.MaxRetriesCount, c.RetryInterval) +} + +func (c ResilientCloudFoundryRestClient) CreateUserProvidedServiceInstance(serviceName string, spaceGuid string, credentials map[string]string) (models.CloudFoundryServiceInstance, error) { + return retryOnError(func() (models.CloudFoundryServiceInstance, error) { + return c.CloudFoundryRestClient.CreateUserProvidedServiceInstance(serviceName, spaceGuid, credentials) + }, c.MaxRetriesCount, c.RetryInterval) +} + func retryOnError[T any](operation func() (T, error), retries int, retryInterval time.Duration) (T, error) { result, err := operation() for shouldRetry(retries, err) { diff --git a/clients/cfrestclient/rest_cloud_foundry_client_extended.go b/clients/cfrestclient/rest_cloud_foundry_client_extended.go index 6e3dd5a..e69e87a 100644 --- a/clients/cfrestclient/rest_cloud_foundry_client_extended.go +++ b/clients/cfrestclient/rest_cloud_foundry_client_extended.go @@ -1,6 +1,7 @@ package cfrestclient import ( + "bytes" "crypto/md5" "crypto/tls" "encoding/hex" @@ -60,7 +61,7 @@ func (c CloudFoundryRestClient) GetAppProcessStatistics(appGuid string) ([]model apiEndpoint, _ := c.cliConn.ApiEndpoint() getAppProcessStatsUrl := fmt.Sprintf("%s/%sapps/%s/processes/web/stats", apiEndpoint, cfBaseUrl, appGuid) - body, err := executeRequest(getAppProcessStatsUrl, token, c.isSslDisabled) + body, err := executeRequest(http.MethodGet, getAppProcessStatsUrl, token, c.isSslDisabled, nil) if err != nil { return nil, err } @@ -114,10 +115,82 @@ func (c CloudFoundryRestClient) GetServiceBindings(serviceName string) ([]models return getPaginatedResourcesWithIncluded(getServiceBindingsUrl, token, c.isSslDisabled, buildServiceBinding) } +func (c CloudFoundryRestClient) GetServiceInstanceByName(serviceName, spaceGuid string) (models.CloudFoundryServiceInstance, error) { + token, err := c.cliConn.AccessToken() + if err != nil { + return models.CloudFoundryServiceInstance{}, fmt.Errorf("failed to retrieve access token: %s", err) + } + apiEndpoint, _ := c.cliConn.ApiEndpoint() + + getServicesUrl := fmt.Sprintf("%s/%sservice_instances?names=%s&space_guids=%s", + apiEndpoint, cfBaseUrl, serviceName, spaceGuid) + services, err := getPaginatedResourcesWithIncluded(getServicesUrl, token, c.isSslDisabled, buildServiceInstance) + if err != nil { + return models.CloudFoundryServiceInstance{}, err + } + if len(services) == 0 { + return models.CloudFoundryServiceInstance{}, fmt.Errorf("service instance not found") + } + + resultService := services[0] + return resultService, nil +} + +func (c CloudFoundryRestClient) CreateUserProvidedServiceInstance(serviceName string, spaceGuid string, credentials map[string]string) (models.CloudFoundryServiceInstance, error) { + token, err := c.cliConn.AccessToken() + if err != nil { + return models.CloudFoundryServiceInstance{}, fmt.Errorf("failed to retrieve access token: %s", err) + } + + apiEndpoint, _ := c.cliConn.ApiEndpoint() + + createServiceURL := fmt.Sprintf("%s/%sservice_instances", apiEndpoint, cfBaseUrl) + + payload := map[string]any{ + "type": "user-provided", + "name": serviceName, + "relationships": map[string]any{ + "space": map[string]any{ + "data": map[string]any{ + "guid": spaceGuid, + }, + }, + }, + } + + if credentials != nil { + payload["credentials"] = credentials + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return models.CloudFoundryServiceInstance{}, fmt.Errorf("failed to marshal create UPS request: %w", err) + } + + body, err := executeRequest(http.MethodPost, createServiceURL, token, c.isSslDisabled, jsonBody) + if err != nil { + return models.CloudFoundryServiceInstance{}, err + } + + response, err := parseBody[models.CloudFoundryUserProvidedServiceInstance](body) + if err != nil { + return models.CloudFoundryServiceInstance{}, err + } + + return models.CloudFoundryServiceInstance{ + Guid: response.Guid, + Name: response.Name, + Type: response.Type, + LastOperation: response.LastOperation, + SpaceGuid: response.SpaceGuid, + Metadata: response.Metadata, + }, nil +} + func getPaginatedResources[T any](url, token string, isSslDisabled bool) ([]T, error) { var result []T for url != "" { - body, err := executeRequest(url, token, isSslDisabled) + body, err := executeRequest(http.MethodGet, url, token, isSslDisabled, nil) if err != nil { return nil, err } @@ -137,7 +210,7 @@ func getPaginatedResources[T any](url, token string, isSslDisabled bool) ([]T, e func getPaginatedResourcesWithIncluded[T any, Auxiliary any](url, token string, isSslDisabled bool, auxiliaryContentHandler func(T, Auxiliary) T) ([]T, error) { var result []T for url != "" { - body, err := executeRequest(url, token, isSslDisabled) + body, err := executeRequest(http.MethodGet, url, token, isSslDisabled, nil) if err != nil { return nil, err } @@ -154,9 +227,22 @@ func getPaginatedResourcesWithIncluded[T any, Auxiliary any](url, token string, return result, nil } -func executeRequest(url, token string, isSslDisabled bool) ([]byte, error) { - req, _ := http.NewRequest(http.MethodGet, url, nil) - req.Header.Add("Authorization", token) +func executeRequest(methodType, url, token string, isSslDisabled bool, body []byte) ([]byte, error) { + var reader io.Reader + + if body != nil { + reader = bytes.NewReader(body) + } + request, err := http.NewRequest(methodType, url, reader) + if err != nil { + return nil, err + } + + request.Header.Add("Authorization", token) + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } // Create transport with TLS configuration httpTransport := http.DefaultTransport.(*http.Transport).Clone() @@ -166,7 +252,7 @@ func executeRequest(url, token string, isSslDisabled bool) ([]byte, error) { userAgentTransport := baseclient.NewUserAgentTransport(httpTransport) client := &http.Client{Transport: userAgentTransport} - resp, err := client.Do(req) + resp, err := client.Do(request) if err != nil { return nil, err } diff --git a/clients/models/cf_services_response.go b/clients/models/cf_services_response.go index c69378e..d75210e 100644 --- a/clients/models/cf_services_response.go +++ b/clients/models/cf_services_response.go @@ -14,6 +14,15 @@ type CloudFoundryServiceInstance struct { Offering ServiceOffering `json:"-"` } +type CloudFoundryUserProvidedServiceInstance struct { + Guid string `json:"guid"` + Name string `json:"name"` + Type string `json:"type"` + LastOperation LastOperation `json:"last_operation,omitempty"` + SpaceGuid string `jsonry:"relationships.space.data.guid"` + Metadata Metadata `json:"metadata"` +} + type LastOperation struct { Type string `json:"type"` State string `json:"state"` diff --git a/clients/mtaclient/mta_rest_client.go b/clients/mtaclient/mta_rest_client.go index 35a6e45..0b5daf4 100644 --- a/clients/mtaclient/mta_rest_client.go +++ b/clients/mtaclient/mta_rest_client.go @@ -41,6 +41,7 @@ type AsyncUploadJobResult struct { Error string `json:"error,omitempty"` File *models.FileMetadata `json:"file,omitempty"` MtaId string `json:"mta_id,omitempty"` + SchemaVersion string `json:"schema_version,omitempty"` BytesProcessed int64 `json:"bytes_processed,omitempty"` ClientActions []string `json:"client_actions,omitempty"` } diff --git a/commands/blue_green_deploy_command.go b/commands/blue_green_deploy_command.go index 0f8fb1f..a40b0d9 100644 --- a/commands/blue_green_deploy_command.go +++ b/commands/blue_green_deploy_command.go @@ -20,7 +20,7 @@ type BlueGreenDeployCommand struct { // NewBlueGreenDeployCommand creates a new BlueGreenDeployCommand. func NewBlueGreenDeployCommand() *BlueGreenDeployCommand { baseCmd := &BaseCommand{flagsParser: deployCommandLineArgumentsParser{}, flagsValidator: deployCommandFlagsValidator{}} - deployCmd := &DeployCommand{baseCmd, blueGreenDeployProcessParametersSetter(), &blueGreenDeployCommandProcessTypeProvider{}, os.Stdin, 30 * time.Second} + deployCmd := &DeployCommand{baseCmd, blueGreenDeployProcessParametersSetter(), &blueGreenDeployCommandProcessTypeProvider{}, os.Stdin, 30 * time.Second, nil} bgDeployCmd := &BlueGreenDeployCommand{deployCmd} baseCmd.Command = bgDeployCmd return bgDeployCmd diff --git a/commands/deploy_command.go b/commands/deploy_command.go index ea1f91e..6c11ec7 100644 --- a/commands/deploy_command.go +++ b/commands/deploy_command.go @@ -18,41 +18,46 @@ import ( "code.cloudfoundry.org/cli/v8/cf/terminal" "code.cloudfoundry.org/cli/v8/plugin" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/baseclient" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/cfrestclient" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/cfrestclient/resilient" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/models" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/mtaclient" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands/retrier" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/configuration" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/log" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/secure_parameters" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/ui" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/util" "gopkg.in/cheggaaa/pb.v1" ) const ( - extDescriptorsOpt = "e" - timeoutOpt = "t" - versionRuleOpt = "version-rule" - noStartOpt = "no-start" - deleteServiceKeysOpt = "delete-service-keys" - keepFilesOpt = "keep-files" - skipOwnershipValidationOpt = "skip-ownership-validation" - moduleOpt = "m" - resourceOpt = "r" - allModulesOpt = "all-modules" - allResourcesOpt = "all-resources" - strategyOpt = "strategy" - skipTestingPhase = "skip-testing-phase" - skipIdleStart = "skip-idle-start" - startTimeoutOpt = "apps-start-timeout" - stageTimeoutOpt = "apps-stage-timeout" - uploadTimeoutOpt = "apps-upload-timeout" - taskExecutionTimeoutOpt = "apps-task-execution-timeout" - applyNamespaceAppNamesOpt = "apply-namespace-app-names" - applyNamespaceServiceNamesOpt = "apply-namespace-service-names" - applyNamespaceAppRoutesOpt = "apply-namespace-app-routes" - applyNamespaceAsSuffix = "apply-namespace-as-suffix" - maxNamespaceSize = 36 - shouldBackupPreviousVersionOpt = "backup-previous-version" + extDescriptorsOpt = "e" + timeoutOpt = "t" + versionRuleOpt = "version-rule" + noStartOpt = "no-start" + deleteServiceKeysOpt = "delete-service-keys" + keepFilesOpt = "keep-files" + skipOwnershipValidationOpt = "skip-ownership-validation" + moduleOpt = "m" + resourceOpt = "r" + allModulesOpt = "all-modules" + allResourcesOpt = "all-resources" + strategyOpt = "strategy" + skipTestingPhase = "skip-testing-phase" + skipIdleStart = "skip-idle-start" + startTimeoutOpt = "apps-start-timeout" + stageTimeoutOpt = "apps-stage-timeout" + uploadTimeoutOpt = "apps-upload-timeout" + taskExecutionTimeoutOpt = "apps-task-execution-timeout" + applyNamespaceAppNamesOpt = "apply-namespace-app-names" + applyNamespaceServiceNamesOpt = "apply-namespace-service-names" + applyNamespaceAppRoutesOpt = "apply-namespace-app-routes" + applyNamespaceAsSuffix = "apply-namespace-as-suffix" + maxNamespaceSize = 36 + shouldBackupPreviousVersionOpt = "backup-previous-version" + requireSecureParameters = "require-secure-parameters" + disposableUserProvidedServiceOpt = "disposable-user-provided-service" ) type listFlag struct { @@ -87,16 +92,23 @@ type DeployCommand struct { FileUrlReader fs.File FileUrlReadTimeout time.Duration + CfClient cfrestclient.CloudFoundryOperationsExtended } // NewDeployCommand creates a new deploy command. func NewDeployCommand() *DeployCommand { baseCmd := &BaseCommand{flagsParser: deployCommandLineArgumentsParser{}, flagsValidator: deployCommandFlagsValidator{}} - deployCmd := &DeployCommand{baseCmd, deployProcessParametersSetter(), &deployCommandProcessTypeProvider{}, os.Stdin, 30 * time.Second} + deployCmd := &DeployCommand{baseCmd, deployProcessParametersSetter(), &deployCommandProcessTypeProvider{}, os.Stdin, 30 * time.Second, nil} baseCmd.Command = deployCmd return deployCmd } +func (c *DeployCommand) Initialize(name string, cliConnection plugin.CliConnection) { + c.BaseCommand.Initialize(name, cliConnection) + delegate := cfrestclient.NewCloudFoundryRestClient(cliConnection) + c.CfClient = resilient.NewResilientCloudFoundryClient(delegate, maxRetriesCount, retryIntervalInSeconds) +} + // GetPluginCommand returns the plugin command details func (c *DeployCommand) GetPluginCommand() plugin.Command { return plugin.Command{ @@ -105,13 +117,13 @@ func (c *DeployCommand) GetPluginCommand() plugin.Command { UsageDetails: plugin.Usage{ Usage: `Deploy a multi-target app archive - cf deploy MTA [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u URL] [-f] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT] + cf deploy MTA [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u URL] [-f] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--require-secure-parameters] [--disposable-user-provided-service] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT] Perform action on an active deploy operation cf deploy -i OPERATION_ID -a ACTION [-u URL] Deploy a multi-target app archive referenced by a remote URL - | cf deploy [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u MTA_CONTROLLER_URL] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT]` + util.UploadEnvHelpText, + | cf deploy [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u MTA_CONTROLLER_URL] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [require-secure-parameters] [--disposable-user-provided-service] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT]` + util.UploadEnvHelpText, Options: map[string]string{ extDescriptorsOpt: "Extension descriptors", @@ -147,6 +159,8 @@ func (c *DeployCommand) GetPluginCommand() plugin.Command { util.CombineFullAndShortParameters(startTimeoutOpt, timeoutOpt): "Start app timeout in seconds", util.GetShortOption(shouldBackupPreviousVersionOpt): "(EXPERIMENTAL) (STRATEGY: BLUE-GREEN, INCREMENTAL-BLUE-GREEN) Backup previous version of applications, use new cli command \"rollback-mta\" to rollback to the previous version", util.GetShortOption(dependencyAwareStopOrderOpt): "(EXPERIMENTAL) (STRATEGY: BLUE-GREEN, INCREMENTAL-BLUE-GREEN) Stop apps in a dependency-aware order during the resume phase of a blue-green deployment", + util.GetShortOption(requireSecureParameters): "(EXPERIMENTAL) Pass secrets to the deploy service in a secure way", + util.GetShortOption(disposableUserProvidedServiceOpt): "Deploy when --require-secure-parameters flag is active for disposable UPS to be created and then deleted at the of the operation", }, }, } @@ -172,6 +186,8 @@ func deployProcessParametersSetter() ProcessParametersSetter { processBuilder.Parameter("appsStageTimeout", GetStringOpt(stageTimeoutOpt, flags)) processBuilder.Parameter("appsUploadTimeout", GetStringOpt(uploadTimeoutOpt, flags)) processBuilder.Parameter("appsTaskExecutionTimeout", GetStringOpt(taskExecutionTimeoutOpt, flags)) + processBuilder.Parameter("isSecurityEnabled", strconv.FormatBool(GetBoolOpt(requireSecureParameters, flags))) + processBuilder.Parameter("isDisposableUserProvidedServiceEnabled", strconv.FormatBool(GetBoolOpt(disposableUserProvidedServiceOpt, flags))) var lastSetValue string = "" for i := 0; i < len(os.Args); i++ { @@ -227,6 +243,8 @@ func (c *DeployCommand) defineCommandOptions(flags *flag.FlagSet) { flags.String(taskExecutionTimeoutOpt, "", "") flags.Bool(shouldBackupPreviousVersionOpt, false, "") flags.Bool(dependencyAwareStopOrderOpt, false, "") + flags.Bool(requireSecureParameters, false, "") + flags.Bool(disposableUserProvidedServiceOpt, false, "") } func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, flags *flag.FlagSet, cfTarget util.CloudFoundryTarget) ExecutionStatus { @@ -261,6 +279,7 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, var uploadedArchivePartIds []string var uploadStatus ExecutionStatus var mtaId string + var disposableUserProvidedServiceName string // Check SLMP metadata // TODO: ensure session @@ -273,15 +292,17 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, sequentialUpload := conf.GetUploadChunksSequentially() disableProgressBar := conf.GetDisableUploadProgressBar() fileUploader := NewFileUploader(mtaClient, namespace, uploadChunkSize, sequentialUpload, disableProgressBar) + var yamlBytes []byte if isUrl { var fileId string + var schemaVersion string asyncUploadJobResult := c.uploadFromUrl(mtaArchive, mtaClient, namespace, disableProgressBar) if asyncUploadJobResult.ExecutionStatus == Failure { return Failure } - mtaId, fileId = asyncUploadJobResult.MtaId, asyncUploadJobResult.FileId + mtaId, fileId, schemaVersion = asyncUploadJobResult.MtaId, asyncUploadJobResult.FileId, asyncUploadJobResult.SchemaVersion // Check for an ongoing operation for this MTA ID and abort it wasAborted, err := c.CheckOngoingOperation(mtaId, namespace, dsHost, force, cfTarget) if err != nil { @@ -293,6 +314,14 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, } uploadedArchivePartIds = append(uploadedArchivePartIds, fileId) + + if GetBoolOpt(requireSecureParameters, flags) { + result := setUpSpecificsForDeploymentUsingSecrets(flags, c, mtaId, namespace, schemaVersion, &disposableUserProvidedServiceName, &yamlBytes) + if result != Success { + return Failure + } + } + ui.Ok() } else { // Get the full path of the MTA archive @@ -323,6 +352,13 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, return Failure } + if GetBoolOpt(requireSecureParameters, flags) { + result := setUpSpecificsForDeploymentUsingSecrets(flags, c, mtaId, namespace, descriptor.SchemaVersion, &disposableUserProvidedServiceName, &yamlBytes) + if result != Success { + return Failure + } + } + // Upload the MTA archive file uploadedArchivePartIds, uploadStatus = c.uploadFiles([]string{mtaArchivePath}, fileUploader) if uploadStatus == Failure { @@ -350,6 +386,15 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, return Failure } + if GetBoolOpt(requireSecureParameters, flags) { + secureFileID, err := fileUploader.UploadBytes("__mta.secure.mtaext", yamlBytes) + if err != nil { + ui.Failed("Could not upload secure extension: %s", err) + return Failure + } + uploadedExtDescriptorIDs = append(uploadedExtDescriptorIDs, secureFileID) + } + // Build the process instance processBuilder := NewDeploymentStrategy(flags, c.processTypeProvider).CreateProcessBuilder() processBuilder.Namespace(namespace) @@ -361,6 +406,7 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, processBuilder.Parameter("appArchiveId", strings.Join(uploadedArchivePartIds, ",")) processBuilder.Parameter("mtaExtDescriptorId", strings.Join(uploadedExtDescriptorIDs, ",")) processBuilder.Parameter("mtaId", mtaId) + processBuilder.Parameter("disposableUserProvidedServiceName", disposableUserProvidedServiceName) setModulesAndResourcesListParameters(modulesList, resourcesList, processBuilder, mtaElementsCalculator) c.setProcessParameters(flags, processBuilder) @@ -376,6 +422,67 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, return executionMonitor.Monitor() } +func setUpSpecificsForDeploymentUsingSecrets(flags *flag.FlagSet, c *DeployCommand, mtaId, namespace, schemaVersion string, disposableUserProvidedServiceName *string, yamlBytes *[]byte) ExecutionStatus { + // Collect special ENVs: __MTA___, __MTA_JSON___, __MTA_CERT___ + parameters, err := secure_parameters.CollectFromEnv("__MTA") + if err != nil { + ui.Failed("Secure parameters error: %s", err) + return Failure + } + + if len(parameters) == 0 { + ui.Failed("No secure parameters found in environment. Set variables like __MTA___, __MTA_JSON___, or __MTA_CERT___.") + return Failure + } + + if GetBoolOpt(disposableUserProvidedServiceOpt, flags) { + disposableUserProvidedServiceNameResult, err := secure_parameters.GetRandomisedUpsName(mtaId, namespace) + if err != nil { + ui.Failed("Failed to create disposable user-provided service name: %v", err) + return Failure + } + + isDisposableUpsCreated, _, err := secure_parameters.CreateDisposableUps(disposableUserProvidedServiceNameResult, c.cliConnection, c.CfClient) + if err != nil { + ui.Failed("Could not ensure disposable user-provided service %s: %v", disposableUserProvidedServiceName, err) + return Failure + } + + *disposableUserProvidedServiceName = disposableUserProvidedServiceNameResult + if isDisposableUpsCreated { + ui.Say("Created disposable user-provided service %s for secure parameters. Will be automatically deleted at the end of the operation!", terminal.EntityNameColor(disposableUserProvidedServiceNameResult)) + } + } else { + userProvidedServiceName := secure_parameters.GetUpsName(mtaId, namespace) + + isUpsCreated, _, err := secure_parameters.ValidateUpsExistsOrElseCreateIt(userProvidedServiceName, c.cliConnection, c.CfClient) + if err != nil { + ui.Failed("Could not ensure user-provided service %s: %v", userProvidedServiceName, err) + return Failure + } + + *disposableUserProvidedServiceName = "" + if isUpsCreated { + ui.Say("Created user-provided service %s for secure parameters.", terminal.EntityNameColor(userProvidedServiceName)) + } else { + ui.Say("Using existing user-provided service %s for secure parameters.", terminal.EntityNameColor(userProvidedServiceName)) + } + } + + yamlBytesResult, err := secure_parameters.BuildSecureExtension(parameters, mtaId, schemaVersion) + if err != nil { + ui.Failed("Could not build secure extension: %s", err) + return Failure + } + if len(yamlBytesResult) == 0 { + ui.Failed("Secure extension descriptor is empty: %s", err) + return Failure + } + *yamlBytes = yamlBytesResult + + return Success +} + func parseMtaArchiveArgument(rawMtaArchive interface{}) (bool, string) { switch castedMtaArchive := rawMtaArchive.(type) { case *url.URL: @@ -406,6 +513,7 @@ func (c *DeployCommand) doUploadFromUrl(encodedFileUrl string, mtaClient mtaclie return UploadFromUrlStatus{ FileId: "", MtaId: "", + SchemaVersion: "", ClientActions: make([]string, 0), ExecutionStatus: Failure, } @@ -435,6 +543,7 @@ func (c *DeployCommand) doUploadFromUrl(encodedFileUrl string, mtaClient mtaclie return UploadFromUrlStatus{ FileId: "", MtaId: "", + SchemaVersion: "", ClientActions: jobResult.ClientActions, ExecutionStatus: Failure, } @@ -445,6 +554,7 @@ func (c *DeployCommand) doUploadFromUrl(encodedFileUrl string, mtaClient mtaclie return UploadFromUrlStatus{ FileId: "", MtaId: "", + SchemaVersion: "", ClientActions: jobResult.ClientActions, ExecutionStatus: Failure, } @@ -467,6 +577,7 @@ func (c *DeployCommand) doUploadFromUrl(encodedFileUrl string, mtaClient mtaclie return UploadFromUrlStatus{ FileId: "", MtaId: "", + SchemaVersion: "", ClientActions: make([]string, 0), ExecutionStatus: Failure, } @@ -480,6 +591,7 @@ func (c *DeployCommand) doUploadFromUrl(encodedFileUrl string, mtaClient mtaclie return UploadFromUrlStatus{ FileId: file.ID, MtaId: jobResult.MtaId, + SchemaVersion: jobResult.SchemaVersion, ClientActions: jobResult.ClientActions, ExecutionStatus: Success, } diff --git a/commands/deploy_command_test.go b/commands/deploy_command_test.go index e89587a..6f12eb9 100644 --- a/commands/deploy_command_test.go +++ b/commands/deploy_command_test.go @@ -2,6 +2,7 @@ package commands_test import ( "encoding/base64" + "errors" "fmt" "io" "io/fs" @@ -12,6 +13,7 @@ import ( "time" cli_fakes "github.com/cloudfoundry-incubator/multiapps-cli-plugin/cli/fakes" + cf_client_fakes "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/cfrestclient/fakes" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/models" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/mtaclient" mtafake "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/mtaclient/fakes" @@ -78,6 +80,7 @@ var _ = Describe("DeployCommand", func() { const testArchive = "mtaArchive.mtar" const mtaArchivePath = testFilesLocation + testArchive const extDescriptorPath = testFilesLocation + "extDescriptor.mtaext" + const userProvidedServiceSecurityRelated = "__mta-secure-anatz" var name string var cliConnection *plugin_fakes.FakeCliConnection @@ -105,39 +108,68 @@ var _ = Describe("DeployCommand", func() { } } - var getOutputLines = func(extDescriptor, processAborted, fromUrl bool) []string { + var getOutputLines = func(extDescriptor, processAborted, fromUrl, existentUserProvidedServiceSecurity, createdUserProvidedServiceSecurity bool) []string { var lines []string mtaNameToPrint := mtaArchivePath if fromUrl { mtaNameToPrint = "from url" } + lines = append(lines, - "Deploying multi-target app archive "+mtaNameToPrint+" in org "+org+" / space "+space+" as "+user+"...") - lines = append(lines, "") + "Deploying multi-target app archive "+mtaNameToPrint+" in org "+org+" / space "+space+" as "+user+"...", + "", + ) + if processAborted { lines = append(lines, "Executing action \"abort\" on operation test-process-id...", "OK", ) } - if fromUrl { - lines = append(lines, "OK") - } else { + + if !fromUrl { + if existentUserProvidedServiceSecurity { + lines = append(lines, + "Using existing user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } + + if createdUserProvidedServiceSecurity { + lines = append(lines, + "Created user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } + lines = append(lines, "Uploading 1 files...", " "+fullMtaArchivePath, - "OK") + "OK", + ) + } else { + if existentUserProvidedServiceSecurity { + lines = append(lines, + "Using existing user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } + + if createdUserProvidedServiceSecurity { + lines = append(lines, + "Created user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } + lines = append(lines, "OK") } + if extDescriptor { lines = append(lines, "Uploading 1 files...", " "+fullExtDescriptorPath, - "OK") + "OK", + ) } + lines = append(lines, "Test message", "Process finished.", - "Use \"cf dmol -i 1000\" to download the logs of the process.") + "Use \"cf dmol -i 1000\" to download the logs of the process.", + ) + return lines } @@ -171,6 +203,7 @@ var _ = Describe("DeployCommand", func() { BeforeEach(func() { ui.DisableTerminalOutput(true) + command = commands.NewDeployCommand() name = command.GetPluginCommand().Name cliConnection = cli_fakes.NewFakeCliConnectionBuilder(). CurrentOrg("test-org-guid", org, nil). @@ -185,8 +218,9 @@ var _ = Describe("DeployCommand", func() { jobId := "one" fileUploadJobId.Add("Location", jobId) jobResult := mtaclient.AsyncUploadJobResult{ - File: mtaArchive, - MtaId: "anatz", + File: mtaArchive, + MtaId: "anatz", + SchemaVersion: "3.1.0", } mtaClient = mtafake.NewFakeMtaClientBuilder(). GetMtaFiles([]*models.FileMetadata{&testutil.SimpleFile}, nil). @@ -200,7 +234,6 @@ var _ = Describe("DeployCommand", func() { GetMtaOperationLogContent("1000", testutil.LogID, testutil.LogContent, nil). GetMtaOperations(nil, nil, nil, []*models.Operation{&testutil.OperationResult}, nil).Build() testClientFactory = commands.NewTestClientFactory(mtaClient, nil, nil) - command = commands.NewDeployCommand() testTokenFactory := commands.NewTestTokenFactory(cliConnection) deployServiceURLCalculator := util_fakes.NewDeployServiceURLFakeCalculator("deploy-service.test.ondemand.com") command.InitializeAll(name, cliConnection, testutil.NewCustomTransport(200), testClientFactory, testTokenFactory, deployServiceURLCalculator) @@ -246,7 +279,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, false, false)) }) }) @@ -348,7 +381,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -360,7 +393,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath, "-e", extDescriptorPath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(true, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(true, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -372,7 +405,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath, "-f", "-delete-services", "-no-start", "-keep-files", "-do-not-fail-on-missing-permissions"}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(true), operation.Parameters) }) @@ -412,7 +445,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -494,5 +527,89 @@ var _ = Describe("DeployCommand", func() { ex.ExpectSuccessWithOutput(status, output, getLinesForAbortingProcess()) }) }) + + Context("with --require-secure-parameters flag and a user-provided service instance which already exists", func() { + It("should not create a new user-provided service", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + upsName := "__mta-secure-anatz" + command.CfClient = &cf_client_fakes.FakeCloudFoundryClient{ + Services: []models.CloudFoundryServiceInstance{{ + Guid: "ups-guid", + Name: upsName}, + }, + ServiceBindingsErr: nil, + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, true, false)) + Expect(output).To(ContainElement(ContainSubstring("Using existing user-provided service"))) + Expect(output).To(ContainElement(ContainSubstring(upsName))) + + callCount := mtaClient.StartMtaOperationCallCount() + Expect(callCount).To(BeNumerically(">", 0)) + operation := mtaClient.StartMtaOperationArgsForCall(callCount - 1) + Expect(operation.Parameters["isSecurityEnabled"]).To(Equal("true")) + }) + }) + + Context("with --require-secure-parameters flag and a user-provided service instance missing", func() { + It("should create a new user-provided service using the appropriate cf command", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + upsName := "__mta-secure-anatz" + command.CfClient = &cf_client_fakes.FakeCloudFoundryClient{ + Services: []models.CloudFoundryServiceInstance{{ + Guid: "ups-guid", + Name: upsName}, + }, + ServiceBindingsErr: errors.New("service instance not found"), + ServicesErr: nil, + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, false, true)) + Expect(output).To(ContainElement(ContainSubstring("Created user-provided service"))) + Expect(output).To(ContainElement(ContainSubstring("__mta-secure-anatz"))) + + callCount := mtaClient.StartMtaOperationCallCount() + Expect(callCount).To(BeNumerically(">", 0)) + operation := mtaClient.StartMtaOperationArgsForCall(callCount - 1) + Expect(operation.Parameters["isSecurityEnabled"]).To(Equal("true")) + }) + }) + + Context("with --require-secure-parameters and `cf services` fails", func() { + It("should return an error from the UPS existence check", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + command.CfClient = &cf_client_fakes.FakeCloudFoundryClient{ + Services: []models.CloudFoundryServiceInstance{{Name: "fakeName"}}, + ServiceBindingsErr: errors.New("error with cf api"), + ServicesErr: nil, + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectFailure(status, output, "") + Expect(output).To(ContainElement(ContainSubstring("Could not ensure user-provided service"))) + Expect(mtaClient.StartMtaOperationCallCount()).To(Equal(0)) + }) + }) + }) }) diff --git a/commands/file_uploader.go b/commands/file_uploader.go index 3320b20..28d7258 100644 --- a/commands/file_uploader.go +++ b/commands/file_uploader.go @@ -1,6 +1,7 @@ package commands import ( + "bytes" "fmt" "io" "os" @@ -63,6 +64,23 @@ func (r *progressBarReader) Close() error { return nil } +type namedBytesReader struct { + r *bytes.Reader + fileName string +} + +func (n *namedBytesReader) Read(p []byte) (int, error) { + return n.r.Read(p) +} + +func (n *namedBytesReader) Seek(o int64, w int) (int64, error) { + return n.r.Seek(o, w) +} + +func (n *namedBytesReader) Name() string { + return n.fileName +} + // NewFileUploader creates a new file uploader for the specified namespace func NewFileUploader(mtaClient mtaclient.MtaClientOperations, namespace string, uploadChunkSizeInMB uint64, sequentialUpload, shouldDisableProgressBar bool) *FileUploader { @@ -249,3 +267,12 @@ func (f *FileUploader) isFileAlreadyUploaded(newFilePath string, fileInfo os.Fil } return false } + +func (f *FileUploader) UploadBytes(filename string, content []byte) (string, error) { + nb := &namedBytesReader{r: bytes.NewReader(content), fileName: filename} + uploadedFile, err := f.mtaClient.UploadMtaFile(nb, int64(len(content)), &f.namespace) + if err != nil { + return "", fmt.Errorf("Could not upload in-memory file %s: %w", filename, err) + } + return uploadedFile.ID, nil +} diff --git a/commands/upload_from_url_status.go b/commands/upload_from_url_status.go index b5b9319..8c40eba 100644 --- a/commands/upload_from_url_status.go +++ b/commands/upload_from_url_status.go @@ -3,6 +3,7 @@ package commands type UploadFromUrlStatus struct { FileId string MtaId string + SchemaVersion string ClientActions []string ExecutionStatus ExecutionStatus } diff --git a/secure_parameters/secure_builder.go b/secure_parameters/secure_builder.go new file mode 100644 index 0000000..d61760b --- /dev/null +++ b/secure_parameters/secure_builder.go @@ -0,0 +1,35 @@ +package secure_parameters + +import ( + "errors" + + "gopkg.in/yaml.v3" +) + +func BuildSecureExtension(parameters map[string]ParameterValue, mtaID string, schemaVersion string) ([]byte, error) { + if len(parameters) == 0 { + return nil, errors.New("no secure parameters collected") + } + + if mtaID == "" { + return nil, errors.New("mtaID is required for the secure extension descriptor's field 'extends'") + } + + if schemaVersion == "" { + return nil, errors.New("schemaVersion is required for the secure extension descriptor") + } + + secureExtensionDescriptor := map[string]interface{}{ + "_schema-version": schemaVersion, + "ID": "__mta.secure", + "extends": mtaID, + "parameters": map[string]interface{}{}, + } + + parametersDescriptor := secureExtensionDescriptor["parameters"].(map[string]interface{}) + for name, currentParameterValue := range parameters { + parametersDescriptor[name] = getValue(¤tParameterValue) + } + + return yaml.Marshal(secureExtensionDescriptor) +} diff --git a/secure_parameters/secure_parameters_test.go b/secure_parameters/secure_parameters_test.go new file mode 100644 index 0000000..a8d6b14 --- /dev/null +++ b/secure_parameters/secure_parameters_test.go @@ -0,0 +1,241 @@ +package secure_parameters + +import ( + "encoding/base64" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func setEnv(t *testing.T, nameOfEnv, valueOfEnv string) { + t.Helper() + t.Setenv(nameOfEnv, valueOfEnv) +} + +func TestCollectFromEnv(t *testing.T) { + setEnv(t, "__MTA___fakePassword", "secretValue") + setEnv(t, "__MTA_JSON___fakeJson", `{"a":1,"b":"secretValueJson"}`) + testCertificate := "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n" + setEnv(t, "__MTA_CERT___fakeCertificate", base64.StdEncoding.EncodeToString([]byte(testCertificate))) + setEnv(t, "unrelatedEnvFirst", "exampleValueFirst") + setEnv(t, "unrelatedEnvSecond", "exampleValueSecond") + + resultToTest, err := CollectFromEnv("__MTA") + if err != nil { + t.Fatalf("Collecting environment variables has failed: %s", err.Error()) + } + + testNormalVariable(t, &resultToTest) + + testJsonVariable(t, &resultToTest) + + testCertificateVariable(t, &resultToTest, testCertificate) + + if _, exists := resultToTest["other"]; exists { + t.Fatalf("Unexpected value and environment variable") + } +} + +func testNormalVariable(t *testing.T, resultToTest *map[string]ParameterValue) { + parameterValue, ok := (*resultToTest)["fakePassword"] + if !ok { + t.Fatalf("Missing key 'fakePassword' in map") + } + + if parameterValue.Type != typeString || parameterValue.StringContent != "secretValue" { + t.Fatalf("The value of 'fakePassword' key is not correct") + } +} + +func testJsonVariable(t *testing.T, resultToTest *map[string]ParameterValue) { + jsonValue, ok := (*resultToTest)["fakeJson"] + if !ok { + t.Fatalf("Missing key 'fakeJson' in map") + } + + if jsonValue.Type != typeJSON { + t.Fatalf("The value of 'fakeJson' key is not correct") + } + + castedValue, ok := jsonValue.JSONContent.(map[string]interface{}) + if !ok { + t.Fatal("fakeJson is not an Object") + } + + if firstJsonValue, ok := castedValue["a"].(float64); !ok || firstJsonValue != 1 { + t.Fatalf("The first value of the json is not what it should be: %v", castedValue["a"]) + } + + if castedValue["b"] != "secretValueJson" { + t.Fatalf("The second value of the json is not what it should be: %v", castedValue["b"]) + } +} + +func testCertificateVariable(t *testing.T, resultToTest *map[string]ParameterValue, testCertificate string) { + certificateValue, ok := (*resultToTest)["fakeCertificate"] + + if !ok { + t.Fatalf("The value of the certificate is not present") + } + + if certificateValue.Type != typeMultiline || certificateValue.StringContent != testCertificate { + t.Fatalf("The value of the certificate is not what it should be: %v", certificateValue) + } +} + +func TestCollectFromEnvWhenWrongName(t *testing.T) { + setEnv(t, "__MTA___fake spaced parameter", "x") + + _, err := CollectFromEnv("__MTA") + if err == nil || err.Error() != `invalid secure parameter name "fake spaced parameter"` { + t.Fatalf("Expected invalid name error: %v", err) + } +} + +func TestCollectFromEnvWhenInvalidJson(t *testing.T) { + setEnv(t, "__MTA_JSON___fakeJson", `{wrongFormat - fake}`) + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), "invalid JSON for fakeJson") { + t.Fatalf("Expected invalid JSON error: %v", err) + } +} + +func TestCollectFromEnvWhenDuplicateNames(t *testing.T) { + setEnv(t, "__MTA_JSON___duplicate", `{"fakeValueName":"value"}`) + setEnv(t, "__MTA___duplicate", "randomValue") + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), `secure parameter "duplicate" defined multiple ways`) { + t.Fatalf("Expected duplication error: %v", err) + } +} + +func TestCollectFromEnvWhenInvalidCertificate(t *testing.T) { + setEnv(t, "__MTA_CERT___fakeCertificate", "%**@&@#!#¬Base64*@&$)@!") + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), "invalid base64 for fakeCertificate") { + t.Fatalf("Expected invalid base64 error: %v", err) + } +} + +func TestCollectFromEnvWhenDifferentPrefix(t *testing.T) { + setEnv(t, "__MTA_JSON___myJson", `{"apple":"green"}`) + + result, err := CollectFromEnv("__OTHER") + + if err != nil { + t.Fatalf("Error while trying to collect environment variables with a different prefix: %s", err.Error()) + } + + if len(result) > 0 { + t.Fatalf("There should be zero environment variables collected, but there are: %d", len(result)) + } +} + +func TestBuildSecureExtension(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + "fakeJson": {Type: typeJSON, JSONContent: map[string]interface{}{"secretParameterFirst": "secretValueOne", "secretParameterSecond": "secretValueTwo"}}, + "fakeCertificate": {Type: typeMultiline, StringContent: "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n"}, + } + + yamlResult, err := BuildSecureExtension(parameters, "test-mta", "") + + if err != nil { + t.Fatalf("Error while building the secure extension descriptor: %s", err.Error()) + } + + var unmarshaledBack map[string]interface{} + + err2 := yaml.Unmarshal(yamlResult, &unmarshaledBack) + + if err2 != nil { + t.Fatalf("Error while unmarshaling extension descriptor: %s", err.Error()) + } + + if unmarshaledBack["_schema-version"] != "3.3" { + t.Fatalf("Schema version is not what it should be: %v", unmarshaledBack["_schema-version"]) + } + + if unmarshaledBack["ID"] != "__mta.secure" { + t.Fatalf("ID of the secure extension descriptor is not what it should be: %v", unmarshaledBack["ID"]) + } + + if unmarshaledBack["extends"] != "test-mta" { + t.Fatalf("Extends of secure extension descriptor is not what it should be: %v", unmarshaledBack["extends"]) + } + + parametersUnmarshaled, ok := unmarshaledBack["parameters"].(map[string]interface{}) + + if !ok { + t.Fatalf("Parameters is not a map, but rather: %T", unmarshaledBack["parameters"]) + } + + if parametersUnmarshaled["password"] != "secretValue" { + t.Fatalf("Value of password is incorrect: %v", parametersUnmarshaled["password"]) + } + + fakeJson, ok := parametersUnmarshaled["fakeJson"].(map[string]interface{}) + + if !ok { + t.Fatalf("fakeJson is not an object but: %T", parametersUnmarshaled["fakeJson"]) + } + + if fakeJson["secretParameterFirst"] != "secretValueOne" { + t.Fatalf("fakeJson.secretParameterFirst is not what it should be: %v", fakeJson["secretParameterFirst"]) + } + + if fakeJson["secretParameterSecond"] != "secretValueTwo" { + t.Fatalf("fakeJson.secretParameterSecond is not what it should be: %v", fakeJson["secretParameterSecond"]) + } + + if parametersUnmarshaled["fakeCertificate"] != "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n" { + t.Fatalf("fakeCertificate is not what it should be: %v", parametersUnmarshaled["fakeCertificate"]) + } +} + +func TestBuildSecureExtensionWhenExplicitSchema(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + } + + yamlResult, err := BuildSecureExtension(parameters, "test-mta", "3.1") + + if err != nil { + t.Fatalf("Error while building the secure extension descriotor: %s", err.Error()) + } + + var unmarshaledBack map[string]interface{} + + err2 := yaml.Unmarshal(yamlResult, &unmarshaledBack) + + if err2 != nil { + t.Fatalf("Error while unmarshaling extension descriptor: %s", err.Error()) + } + + if unmarshaledBack["_schema-version"] != "3.1" { + t.Fatalf("Schema version must be 3.1, but it is: %v", unmarshaledBack["_schema-version"]) + } +} + +func TestBuildSecureExtensionWhenNoParameters(t *testing.T) { + _, err := BuildSecureExtension(map[string]ParameterValue{}, "test-mta", "") + + if err == nil || err.Error() != "no secure parameters collected" { + t.Fatalf("Expected no parameters error, but rather got: %v", err) + } +} + +func TestBuildSecureExtensionWhenNoMtaId(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + } + + _, err := BuildSecureExtension(parameters, "", "") + if err == nil || err.Error() != "mtaID is required for the extension descriptor's field 'extends'" { + t.Fatalf("Expected missing mta id error, but rather got: %v", err) + } +} diff --git a/secure_parameters/secure_parametes.go b/secure_parameters/secure_parametes.go new file mode 100644 index 0000000..fef8332 --- /dev/null +++ b/secure_parameters/secure_parametes.go @@ -0,0 +1,144 @@ +package secure_parameters + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" +) + +var nameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +type typeOfValue int + +const ( + typeString typeOfValue = iota + typeJSON + typeMultiline +) + +type ParameterValue struct { + Type typeOfValue + StringContent string + JSONContent interface{} +} + +func validateNoDuplicatesExist(name, prefix string, result map[string]ParameterValue) error { + _, ok := result[name] + if ok { + return fmt.Errorf("secure parameter %q defined multiple ways (collision with %s)", name, prefix) + } + + return nil +} + +func getValue(parameter *ParameterValue) interface{} { + switch parameter.Type { + case typeJSON: + return parameter.JSONContent + + default: + return parameter.StringContent + } +} + +func CollectFromEnv(prefix string) (map[string]ParameterValue, error) { + plainPrefix := prefix + "___" + jsonPrefix := prefix + "_JSON___" + certPrefix := prefix + "_CERT___" + + result := make(map[string]ParameterValue) + + for _, nameValuePair := range os.Environ() { + equalsIndex := strings.IndexByte(nameValuePair, '=') + if equalsIndex < 0 { + continue + } + envName := nameValuePair[:equalsIndex] + envValue := nameValuePair[equalsIndex+1:] + + var name string + + switch { + case strings.HasPrefix(envName, jsonPrefix): + name = strings.TrimPrefix(envName, jsonPrefix) + + err := addJSONValues(name, envValue, result) + if err != nil { + return nil, err + } + case strings.HasPrefix(envName, certPrefix): + name = strings.TrimPrefix(envName, certPrefix) + + err := addCertificateValues(name, envValue, result) + if err != nil { + return nil, err + } + case strings.HasPrefix(envName, plainPrefix): + name = strings.TrimPrefix(envName, plainPrefix) + + err := addPlainValues(name, envValue, result) + if err != nil { + return nil, err + } + default: + continue + } + } + return result, nil +} + +func addJSONValues(name, raw string, result map[string]ParameterValue) error { + if !nameRegex.MatchString(name) { + return fmt.Errorf("invalid secure parameter name %q", name) + } + + errDuplicated := validateNoDuplicatesExist(name, "__MTA_JSON", result) + if errDuplicated != nil { + return errDuplicated + } + var parsed interface{} + + errUnmarshal := json.Unmarshal([]byte(raw), &parsed) + if errUnmarshal != nil { + return fmt.Errorf("invalid JSON for %s: %w", name, errUnmarshal) + } + + result[name] = ParameterValue{Type: typeJSON, JSONContent: parsed} + return nil +} + +func addCertificateValues(name, raw string, result map[string]ParameterValue) error { + if !nameRegex.MatchString(name) { + return fmt.Errorf("invalid secure parameter name %q", name) + } + + err := validateNoDuplicatesExist(name, "__MTA_CERT", result) + if err != nil { + return err + } + + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return fmt.Errorf("invalid base64 for %s: %w", name, err) + } + + result[name] = ParameterValue{Type: typeMultiline, StringContent: string(decoded)} + return nil +} + +func addPlainValues(name, raw string, result map[string]ParameterValue) error { + if !nameRegex.MatchString(name) { + return fmt.Errorf("invalid secure parameter name %q", name) + } + + err := validateNoDuplicatesExist(name, "__MTA", result) + if err != nil { + return err + } + + result[name] = ParameterValue{Type: typeString, StringContent: raw} + return nil +} diff --git a/secure_parameters/secure_ups_util.go b/secure_parameters/secure_ups_util.go new file mode 100644 index 0000000..351d152 --- /dev/null +++ b/secure_parameters/secure_ups_util.go @@ -0,0 +1,126 @@ +package secure_parameters + +import ( + "crypto/rand" + "fmt" + "strings" + + "code.cloudfoundry.org/cli/v8/plugin" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/cfrestclient" +) + +func ValidateUpsExistsOrElseCreateIt(userProvidedServiceName string, cliConnection plugin.CliConnection, cfClient cfrestclient.CloudFoundryOperationsExtended) (upsCreatedByTheCli bool, encryptionKeyResult string, err error) { + doesUpsExist, err := doesUpsExist(userProvidedServiceName, cliConnection, cfClient) + if err != nil { + return false, "", fmt.Errorf("Check if the UPS exists: %w", err) + } + + if doesUpsExist { + return false, "", nil + } + + encryptionKey, err := getRandomEncryptionKey() + if err != nil { + return false, "", fmt.Errorf("Error while generating AES-256 encryption key: %w", err) + } + + space, err := cliConnection.GetCurrentSpace() + if err != nil { + return false, "", fmt.Errorf("Failed to get the current space: %w", err) + } + + if space.Guid == "" { + return false, "", fmt.Errorf("Failed to get the current space Guid") + } + + upsCredentials := map[string]string{ + "encryptionKey": encryptionKey, + } + + _, err = cfClient.CreateUserProvidedServiceInstance(userProvidedServiceName, space.Guid, upsCredentials) + if err != nil { + return false, "", fmt.Errorf("Failed to create user-provided service %s: %w", userProvidedServiceName, err) + } + + return true, encryptionKey, nil +} + +func CreateDisposableUps(userProvidedServiceName string, cliConnection plugin.CliConnection, cfClient cfrestclient.CloudFoundryOperationsExtended) (upsCreatedByTheCli bool, encryptionKeyResult string, err error) { + encryptionKey, err := getRandomEncryptionKey() + if err != nil { + return false, "", fmt.Errorf("Error while generating AES-256 encryption key: %w", err) + } + + space, err := cliConnection.GetCurrentSpace() + if err != nil { + return false, "", fmt.Errorf("Failed to get the current space: %w", err) + } + + if space.Guid == "" { + return false, "", fmt.Errorf("Failed to get the current space Guid") + } + + upsCredentials := map[string]string{ + "encryptionKey": encryptionKey, + } + + _, err = cfClient.CreateUserProvidedServiceInstance(userProvidedServiceName, space.Guid, upsCredentials) + if err != nil { + return false, "", fmt.Errorf("Failed to create user-provided service %s: %w", userProvidedServiceName, err) + } + + return true, encryptionKey, nil +} + +func getRandomEncryptionKey() (string, error) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + + encryptionKeyBytes := make([]byte, 32) + if _, err := rand.Read(encryptionKeyBytes); err != nil { + return "", err + } + + for i := range encryptionKeyBytes { + encryptionKeyBytes[i] = alphabet[int(encryptionKeyBytes[i]&63)] + } + + return string(encryptionKeyBytes), nil +} + +func doesUpsExist(userProvidedServiceName string, cliConnection plugin.CliConnection, cfClient cfrestclient.CloudFoundryOperationsExtended) (bool, error) { + space, errSpace := cliConnection.GetCurrentSpace() + if errSpace != nil { + return false, fmt.Errorf("Cannot determine the current space") + } + spaceGuid := space.Guid + + _, errServiceInstance := cfClient.GetServiceInstanceByName(userProvidedServiceName, spaceGuid) + if errServiceInstance != nil { + if errServiceInstance.Error() == "service instance not found" { + return false, nil + } + return false, fmt.Errorf("Error while checking if the UPS for secure encryption exists: %w", errServiceInstance) + } + + return true, nil +} + +func GetUpsName(mtaId, namespace string) string { + if strings.TrimSpace(namespace) == "" { + return "__mta-secure-" + mtaId + } + return "__mta-secure-" + mtaId + "-" + namespace +} + +func GetRandomisedUpsName(mtaId, namespace string) (disposableUpsName string, err error) { + randomisedPart, err := getRandomEncryptionKey() + if err != nil { + return "", err + } + resultSuffix := randomisedPart[:7] + + if strings.TrimSpace(namespace) == "" { + return "__mta-secure-" + mtaId + "-" + resultSuffix, nil + } + return "__mta-secure-" + mtaId + "-" + namespace + "-" + resultSuffix, nil +}