import com.cloudbees.groovy.cps.NonCPS import com.sap.piper.GenerateDocumentation import com.sap.piper.ConfigurationHelper import com.sap.piper.Utils import com.sap.piper.StepAssertions import com.sap.piper.tools.neo.DeployMode import com.sap.piper.tools.neo.NeoCommandHelper import com.sap.piper.tools.neo.WarAction import groovy.transform.Field import static com.sap.piper.Prerequisites.checkScript @Field String STEP_NAME = getClass().getName() @Field Set GENERAL_CONFIG_KEYS = [ 'neo', /** * The SAP Cloud Platform account to deploy to. * @parentConfigKey neo * @mandatory for deployMode=warParams */ 'account', /** * Name of the application you want to manage, configure, or deploy. * @parentConfigKey neo * @mandatory for deployMode=warParams */ 'application', /** * The Jenkins credentials containing either user and password (UsernamePassword type credential) or json containing clientId, client secret and oauth service url (SecretFile type credential) used for SAP CP deployment. * @parentConfigKey neo */ 'credentialsId', /** * The Jenkins credential of type 'UsernamePassword' or 'SecretFile'. * @parentConfigKey neo */ 'credentialType', /** * Map of environment variables in the form of KEY: VALUE. * @parentConfigKey neo */ 'environment', /** * The SAP Cloud Platform host to deploy to. * @parentConfigKey neo * @mandatory for deployMode=warParams */ 'host', /** * The path to the .properties file in which all necessary deployment properties for the application are defined. * @parentConfigKey neo * @mandatory for deployMode=warPropertiesFile */ 'propertiesFile', /** * Name of SAP Cloud Platform application runtime. * @parentConfigKey neo * @mandatory for deployMode=warParams */ 'runtime', /** * Version of SAP Cloud Platform application runtime. * @parentConfigKey neo * @mandatory for deployMode=warParams */ 'runtimeVersion', /** * Compute unit (VM) size. Acceptable values: lite, pro, prem, prem-plus. * @parentConfigKey neo */ 'size', /** * String of VM arguments passed to the JVM. * @parentConfigKey neo */ 'vmArguments', /** * Boolean to enable/disable invalidating the cache after deployment. * @possibleValues `true`, `false` * @parentConfigKey neo */ 'invalidateCache', /** * Portal landscape region subscribed to in SAP Cloud Platform. * @parentConfigKey neo */ 'portalLandscape', /** * UsernamePassword type credential containing SAP Cloud Platform OAuth client ID and client secret. * @parentConfigKey neo */ 'oauthCredentialId', /** * Site ID of the SAP Fiori Launchpad containing the SAP Fiori app. If not set, the cache of the default site, as defined in the Portal service, is invalidated. * @parentConfigKey neo */ 'siteId', /** * Availability zone of BTP NEO into which the application is deployed. * @possibleValues 1, 2 * @parentConfigKey neo */ 'azDistribution' ] @Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ /** * The deployment mode which should be used. Available options are: * *`'mta'` - default, * *`'warParams'` - deploying WAR file and passing all the deployment parameters via the function call, * *`'warPropertiesFile'` - deploying WAR file and putting all the deployment parameters in a .properties file. * @possibleValues 'mta', 'warParams', 'warPropertiesFile' */ 'deployMode', /** * @see dockerExecute */ 'dockerEnvVars', /** * @see dockerExecute */ 'dockerImage', /** * @see dockerExecute */ 'dockerOptions', /** * Extension files. Provided to the neo command via parameter `--extensions` (`-e`). Only valid for deploy mode `mta`. */ 'extensions', /** * The path to the archive for deployment to SAP CP. If not provided the following defaults are used based on the deployMode: * *`'mta'` - The `mtarFilePath` from common pipeline environment is used instead. * *`'warParams'` and `'warPropertiesFile'` - The following template will be used "/target/." */ 'source', /** * Path to the maven module which contains the deployment artifact. */ 'mavenDeploymentModule' ]) @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS.plus([ /** * Action mode when using WAR file mode. Available options are `deploy` (default) and `rolling-update` which performs update of an application without downtime in one go. * @possibleValues 'deploy', 'rolling-update' */ 'warAction' ]) /** * Deploys an Application to SAP Cloud Platform (SAP CP) using the SAP Cloud Platform Console Client (Neo Java Web SDK). */ @GenerateDocumentation void call(parameters = [:]) { handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters) { def script = checkScript(this, parameters) ?: this def utils = parameters.utils ?: new Utils() String stageName = parameters.stageName ?: env.STAGE_NAME // load default & individual configuration ConfigurationHelper configHelper = ConfigurationHelper.newInstance(this) .loadStepDefaults([:], stageName) .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) .mixinStageConfig(script.commonPipelineEnvironment, stageName, STEP_CONFIG_KEYS) .mixin(parameters, PARAMETER_KEYS) .collectValidationFailures() .withPropertyInValues('deployMode', DeployMode.stringValues()) Map configuration = configHelper.use() DeployMode deployMode = DeployMode.fromString(configuration.deployMode) def isWarParamsDeployMode = { deployMode == DeployMode.WAR_PARAMS }, isNotWarPropertiesDeployMode = {deployMode != DeployMode.WAR_PROPERTIES_FILE} if(!configuration.source){ configHelper.mixin([source: getDefaultSource(script, configuration, deployMode)]) } configuration = configHelper .withMandatoryProperty('source') .withMandatoryProperty('neo/credentialsId') .withMandatoryProperty('neo/credentialType') .withMandatoryProperty('neo/application', null, isWarParamsDeployMode) .withMandatoryProperty('neo/runtime', null, isWarParamsDeployMode) .withMandatoryProperty('neo/runtimeVersion', null, isWarParamsDeployMode) .withMandatoryProperty('neo/host', null, isNotWarPropertiesDeployMode) .withMandatoryProperty('neo/account', null, isNotWarPropertiesDeployMode) .use() Set extensionFileNames if(configuration.extensions == null) { extensionFileNames = [] } else { extensionFileNames = configuration.extensions in Collection ? configuration.extensions : [configuration.extensions] } if( ! extensionFileNames.findAll { it == null || it.isEmpty() }.isEmpty() ) error "At least one extension file name was null or empty: ${extensionFileNames}." if(deployMode != DeployMode.MTA && ! extensionFileNames.isEmpty()) error "Extensions (${extensionFileNames} found for deploy mode ${deployMode}. Extensions are only supported for deploy mode '${DeployMode.MTA}')" utils.pushToSWA([ step: STEP_NAME, stepParamKey1: 'deployMode', stepParam1: configuration.deployMode == 'mta'?'mta':'war', // ['mta', 'warParams', 'warPropertiesFile'] stepParamKey2: 'warAction', stepParam2: configuration.warAction == 'rolling-update'?'blue-green':'standard', // ['deploy', 'deploy-mta', 'rolling-update'] stepParamKey3: 'scriptMissing', stepParam3: parameters?.script == null, ], configuration) if(configuration.neo.credentialType == 'UsernamePassword'){ withCredentials([usernamePassword( credentialsId: configuration.neo.credentialsId, passwordVariable: 'NEO_PASSWORD', usernameVariable: 'NEO_USERNAME')]) { assertPasswordRules(NEO_PASSWORD) dockerExecute( script: script, dockerImage: configuration.dockerImage, dockerEnvVars: configuration.dockerEnvVars, dockerOptions: configuration.dockerOptions ) { StepAssertions.assertFileExists(this, configuration.source) for(CharSequence extensionFile in extensionFileNames) { StepAssertions.assertFileExists(this, extensionFile) } NeoCommandHelper neoCommandHelper = new NeoCommandHelper( this, deployMode, configuration.neo, extensionFileNames, NEO_USERNAME, NEO_PASSWORD, configuration.source ) lock("$STEP_NAME:${neoCommandHelper.resourceLock()}") { deploy(script, configuration, neoCommandHelper, configuration.dockerImage, deployMode) } if(configuration.neo.invalidateCache == true) { if (configuration.deployMode == 'mta') { echo "Triggering invalidation of cache for html5 applications" invalidateCache(configuration) } else { echo "Invalidation of cache is ignored. It is performed only for html5 applications." } } } } } else if(configuration.neo.credentialType == 'SecretFile'){ dockerExecute( script: script, dockerImage: configuration.dockerImage, dockerEnvVars: configuration.dockerEnvVars, dockerOptions: configuration.dockerOptions ) { withCredentials([file(credentialsId: configuration.neo.credentialsId, variable: 'oauth_deploy_cred')]) { deployWithBearerToken(oauth_deploy_cred, configuration, script) } } } else { error "Unsupported type of neo deploy credential." } } } private invalidateCache(configuration){ def account = configuration.neo.account def host = configuration.neo.host def portalLandscape = configuration.neo.portalLandscape withCredentials([usernamePassword( credentialsId: configuration.neo.oauthCredentialId, passwordVariable: 'OAUTH_NEO_CLIENT_SECRET', usernameVariable: 'OAUTH_NEO_CLIENT_ID')]) { def bearerTokenResponse = sh( script: """#!/bin/bash curl -X POST -u "${OAUTH_NEO_CLIENT_ID}:${OAUTH_NEO_CLIENT_SECRET}" \ --fail \ "https://oauthasservices-${account}.${host}/oauth2/api/v1/token?grant_type=client_credentials&scope=write,read" """, returnStdout: true) def bearerToken = readJSON(text: bearerTokenResponse).access_token echo "Retrieved bearer token." def fetchXcsrfTokenResponse = sh( script: """#!/bin/bash curl -i -L \ -c 'cookies.jar' \ -H 'X-CSRF-Token: Fetch' \ -H "Authorization: Bearer ${bearerToken}" \ --fail \ "https://${portalLandscape}-${account}.${host}/fiori/api/v1/csrf" """, returnStdout: true) def xcsrfToken = readProperties(text: fetchXcsrfTokenResponse)["X-CSRF-Token"] def siteId = configuration.neo.siteId ?: "" if(! siteId){ echo "Using the default site defined in Portal service and invalidating the cache." } else{ echo "Invalidating the cache for site with Id: ${siteId}." } def statusCode = sh( script: """#!/bin/bash curl -X POST -L \ -b 'cookies.jar' \ -H "X-CSRF-Token: ${xcsrfToken}" \ -H "Authorization: Bearer ${bearerToken}" \ -d "{\"siteId\":${siteId}}" \ -so /dev/null \ -w '%{response_code}' \ "https://${portalLandscape}-${account}.${host}/fiori/v1/operations/invalidateCache" """, returnStdout: true).trim() if(! siteId && statusCode == "500") { error "Invalidating the cache failed. " + "As no siteId is set, the default site defined in the portal UI is used. " + "Please verify a default site is defined in Portal service. " + "Alternatively, configure the siteId parameter for this step to invalidate the cache of that specific site." } else if(! statusCode == "200" || ! statusCode == "201" ){ error "Invalidating the cache failed with response code: ${statusCode}." } echo "Successfully invalidated the cache." } } private deploy(script, Map configuration, NeoCommandHelper neoCommandHelper, dockerImage, DeployMode deployMode) { String logFolder = "logs/neo/${UUID.randomUUID()}" try { sh "mkdir -p ${logFolder}" withEnv(["neo_logging_location=${pwd()}/${logFolder}"]) { if (deployMode.isWarDeployment()) { ConfigurationHelper.newInstance(this, configuration).withPropertyInValues('warAction', WarAction.stringValues()) WarAction warAction = WarAction.fromString(configuration.warAction) if (warAction == WarAction.ROLLING_UPDATE) { if (!isAppRunning(neoCommandHelper)) { warAction = WarAction.DEPLOY echo "Rolling update not possible because application is not running. Falling back to standard deployment." } } echo "Link to the application dashboard: ${neoCommandHelper.cloudCockpitLink()}" if (warAction == WarAction.ROLLING_UPDATE) { try { sh neoCommandHelper.rollingUpdateCommand() } catch (e) { error "[ERROR][${STEP_NAME}] The execution of the deploy command failed, see the log for details." } } else { try { sh neoCommandHelper.deployCommand() } catch (e) { error "[ERROR][${STEP_NAME}] The execution of the deploy command failed, see the log for details." } sh neoCommandHelper.restartCommand() } } else if (deployMode == DeployMode.MTA) { try { sh neoCommandHelper.deployMta() } catch (e) { error "[ERROR][${STEP_NAME}] The execution of the deploy command failed, see the log for details." } } } } catch (Exception ex) { echo "Error while deploying to SAP Cloud Platform. Here are the neo.sh logs:" try { sh "cat ${logFolder}/*" } catch(Exception e) { echo "Unable to provide the logs." ex.addSuppressed(e) } throw ex } } private deployWithBearerToken(def credentialFilePath, Map configuration, Script script){ def deployArchive = script.commonPipelineEnvironment.getMtarFilePath() def host = configuration.neo.host def account = configuration.neo.account def credentialFileContent = readFile(credentialFilePath) def credentialsMap = parseJson(credentialFileContent) def oauthClientId = credentialsMap.oauthClientId def oauthClientSecret = credentialsMap.oauthClientSecret def oauthUrl = credentialsMap.oauthServiceUrl echo "[${STEP_NAME}] Retrieving oauth token..." def myCurl = "curl --fail --silent --show-error --retry 12" def token_json = sh( script: """#!/bin/bash ${myCurl} -XPOST -u \"${oauthClientId}:${oauthClientSecret}\" \"${oauthUrl}/apitoken/v1?grant_type=client_credentials" """, returnStdout: true ) def responseJson = readJSON text: token_json def token = responseJson.access_token echo "[${STEP_NAME}] Deploying '${deployArchive}' to '${account}'..." def deploymentContentResponse = sh( script: """#!/bin/bash ${myCurl} -XPOST -H \"Authorization: Bearer ${token}\" -F file=@\"${deployArchive}\" \"https://slservice.${host}/slservice/v1/oauth/accounts/${account}/mtars\" """, returnStdout: true ) def deploymentJson = readJSON text: deploymentContentResponse def deploymentId = deploymentJson.id echo "[${STEP_NAME}] Deployment Id is '${deploymentId}'." def statusPollScript = """#!/bin/bash ${myCurl} -XGET -H \"Authorization: Bearer ${token}\" \"https://slservice.${host}/slservice/v1/oauth/accounts/${account}/mtars/${deploymentId}\" """ def statusResponse = sh(script: statusPollScript, returnStdout: true) def statusJson = readJSON text: statusResponse def state = statusJson.state while (state == 'RUNNING') { sleep(10) statusResponse = sh(script: statusPollScript, returnStdout: true) statusJson = readJSON text: statusResponse state = statusJson.state echo "${STEP_NAME}] Deployment is still running..." } if (state == 'DONE') { echo "[${STEP_NAME}] Deployment has succeeded." } else if (state == 'FAILED') { if(statusJson.progress[0]?.modules[0]?.error?.internalMessage) { def message = statusJson.progress[0].modules[0].error.internalMessage echo "[${STEP_NAME}] Deployment has failed with the message: ${message}" error "[${STEP_NAME}] Deployment failure message: ${message}" } else { echo "[${STEP_NAME}] Deployment has failed with response: ${statusResponse}" error "[${STEP_NAME}] Deployment failure reason: ${statusResponse}" } } else { echo "[${STEP_NAME}] Unknown status '${state}'" error "[${STEP_NAME}] Deployment failed with unknown status: ${state}" } } private boolean isAppRunning(NeoCommandHelper commandHelper) { def status = sh script: "${commandHelper.statusCommand()} || true", returnStdout: true return status.contains('Status: STARTED') } private assertPasswordRules(String password) { if (password.startsWith("@")) { error("Your password for the deployment to SAP Cloud Platform contains characters which are not " + "supported by the neo tools. " + "For example it is not allowed that the password starts with @. " + "Please consult the documentation for the neo command line tool for more information: " + "https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/8900b22376f84c609ee9baf5bf67130a.html") } } private getDefaultSource(Script script, Map configuration, DeployMode deployMode){ if(deployMode == DeployMode.MTA) { return script.commonPipelineEnvironment.getMtarFilePath() } String pomFile = "${configuration.mavenDeploymentModule}/pom.xml" if(!fileExists(pomFile)){ error("The configured mavenDeploymentModule (${configuration.mavenDeploymentModule}) does not contain a pom file.") } def pom = readMavenPom file: pomFile String source = "${configuration.mavenDeploymentModule}/target/${pom.artifactId}.${pom.packaging}" return source } //Convert LazyMap instance produced after jsonSluper to a groovy based LinkedHashMap to overcome serialization issue @NonCPS def parseJson(credentialFileContent) { def lazyMap = new groovy.json.JsonSlurper().parseText(credentialFileContent) def map = [:] for (prop in lazyMap) { map[prop.key] = prop.value } return map }