From 17e839051186c8c9378e9bd85e2fcadbb851a3ef Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 12 Oct 2018 16:06:41 +0200 Subject: [PATCH 1/5] add step healthExecuteCheck (#339) This step allows to perform a basic health check on an installed application. It verifies that your app has a simple health endpoint available and that there is no error when calling it. --- .../docs/steps/healthExecuteCheck.md | 62 +++++++++++++ documentation/mkdocs.yml | 1 + resources/default_pipeline_environment.yml | 2 + test/groovy/HealthExecuteCheckTest.groovy | 88 +++++++++++++++++++ vars/healthExecuteCheck.groovy | 46 ++++++++++ 5 files changed, 199 insertions(+) create mode 100644 documentation/docs/steps/healthExecuteCheck.md create mode 100644 test/groovy/HealthExecuteCheckTest.groovy create mode 100644 vars/healthExecuteCheck.groovy diff --git a/documentation/docs/steps/healthExecuteCheck.md b/documentation/docs/steps/healthExecuteCheck.md new file mode 100644 index 000000000..ca0cf1c08 --- /dev/null +++ b/documentation/docs/steps/healthExecuteCheck.md @@ -0,0 +1,62 @@ +# healthExecuteCheck + +## Description +Calls the health endpoint url of the application. + +The intention of the check is to verify that a suitable health endpoint is available. Such a health endpoint is required for operation purposes. + +This check is used as a real-life test for your productive health endpoints. + +!!! note "Check Depth" + Typically, tools performing simple health checks are not too smart. Therefore it is important to choose an endpoint for checking wisely. + + This check therefore only checks if the application/service url returns `HTTP 200`. + + This is in line with health check capabilities of platforms which are used for example in load balancing scenarios. Here you can find an [example for Amazon AWS](http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-healthchecks.html). + + + +## Prerequisites + +Endpoint for health check is configured. + +!!! warning + The health endpoint needs to be available without authentication! + +!!! tip + If using Spring Boot framework, ideally the provided `/health` endpoint is used and extended by development. Further information can be found in the [Spring Boot documenation for Endpoints](http://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html) + + +## Example + +Pipeline step: + +```groovy +healthExecuteCheck testServerUrl: 'https://testserver.com' +``` + +## Parameters + +| parameter | mandatory | default | possible values | +| ----------|-----------|---------|-----------------| +|script|yes||| +|healthEndpoint|no|``|| +|testServerUrl|no||| + + +Details: +* `script` defines the global script environment of the Jenkinsfile run. Typically `this` is passed to this parameter. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for storing the measured duration. +* Health check function is called providing full qualified `testServerUrl` (and optionally with `healthEndpoint` if endpoint is not the standard url) to the health check. +* In case response of the call is different than `HTTP 200 OK` the **health check fails and the pipeline stops**. + +## Step configuration + +We recommend to define values of step parameters via [config.yml file](../configuration.md). + +In following sections the configuration is possible: + +| parameter | general | step | stage | +| ----------|-----------|---------|-----------------| +|script|||| +|healthEndpoint|X|X|X| +|testServerUrl|X|X|X| diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 55601bfe8..b7747d903 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -12,6 +12,7 @@ nav: - dockerExecuteOnKubernetes: steps/dockerExecuteOnKubernetes.md - durationMeasure: steps/durationMeasure.md - handlePipelineStepErrors: steps/handlePipelineStepErrors.md + - healthExecuteCheck: steps/healthExecuteCheck.md - influxWriteData: steps/influxWriteData.md - mavenExecute: steps/mavenExecute.md - mtaBuild: steps/mtaBuild.md diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 7410dacbf..05ac19a78 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -139,6 +139,8 @@ steps: workspace: '**/*.*' stashExcludes: workspace: 'nohup.out' + healthExecuteCheck: + healthEndpoint: '' influxWriteData: influxServer: 'jenkins' mavenExecute: diff --git a/test/groovy/HealthExecuteCheckTest.groovy b/test/groovy/HealthExecuteCheckTest.groovy new file mode 100644 index 000000000..6f2bdda4f --- /dev/null +++ b/test/groovy/HealthExecuteCheckTest.groovy @@ -0,0 +1,88 @@ +#!groovy +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsLoggingRule +import util.JenkinsReadYamlRule +import util.JenkinsStepRule +import util.Rules + +import static org.hamcrest.Matchers.* +import static org.junit.Assert.assertThat + +class HealthExecuteCheckTest extends BasePiperTest { + private JenkinsStepRule jsr = new JenkinsStepRule(this) + private JenkinsLoggingRule jlr = new JenkinsLoggingRule(this) + private ExpectedException thrown = ExpectedException.none() + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(new JenkinsReadYamlRule(this)) + .around(jlr) + .around(jsr) + .around(thrown) + + + @Before + void init() throws Exception { + // register Jenkins commands with mock values + def command1 = "curl -so /dev/null -w '%{response_code}' http://testserver" + def command2 = "curl -so /dev/null -w '%{response_code}' http://testserver/endpoint" + helper.registerAllowedMethod('sh', [Map.class], {map -> + return map.script == command1 || map.script == command2 ? "200" : "404" + }) + } + + @Test + void testHealthCheckOk() throws Exception { + def testUrl = 'http://testserver/endpoint' + + jsr.step.healthExecuteCheck( + script: nullScript, + testServerUrl: testUrl + ) + + assertThat(jlr.log, containsString("Health check for ${testUrl} successful")) + } + + @Test + void testHealthCheck404() throws Exception { + def testUrl = 'http://testserver/404' + + thrown.expect(Exception) + thrown.expectMessage('Health check failed: 404') + + jsr.step.healthExecuteCheck( + script: nullScript, + testServerUrl: testUrl + ) + } + + + @Test + void testHealthCheckWithEndPoint() throws Exception { + jsr.step.healthExecuteCheck( + script: nullScript, + testServerUrl: 'http://testserver', + healthEndpoint: 'endpoint' + ) + + assertThat(jlr.log, containsString("Health check for http://testserver/endpoint successful")) + } + + @Test + void testHealthCheckWithEndPointTrailingSlash() throws Exception { + jsr.step.healthExecuteCheck( + script: nullScript, + testServerUrl: 'http://testserver/', + healthEndpoint: 'endpoint' + ) + + assertThat(jlr.log, containsString("Health check for http://testserver/endpoint successful")) + } + +} diff --git a/vars/healthExecuteCheck.groovy b/vars/healthExecuteCheck.groovy new file mode 100644 index 000000000..13d5ecf06 --- /dev/null +++ b/vars/healthExecuteCheck.groovy @@ -0,0 +1,46 @@ +import com.sap.piper.ConfigurationHelper + +import groovy.transform.Field + +@Field String STEP_NAME = 'healthExecuteCheck' +@Field Set STEP_CONFIG_KEYS = [ + 'healthEndpoint', + 'testServerUrl' +] +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +void call(Map parameters = [:]) { + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { + def script = parameters?.script ?: [commonPipelineEnvironment: commonPipelineEnvironment] + // load default & individual configuration + Map config = ConfigurationHelper + .loadStepDefaults(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixin(parameters, PARAMETER_KEYS) + .withMandatoryProperty('testServerUrl') + .use() + + def checkUrl = config.testServerUrl + if(config.healthEndpoint){ + if(!checkUrl.endsWith('/')) + checkUrl += '/' + checkUrl += config.healthEndpoint + } + + def statusCode = curl(checkUrl) + if (statusCode != '200') { + error "Health check failed: ${statusCode}" + } else { + echo "Health check for ${checkUrl} successful" + } + } +} + +def curl(url){ + return sh( + returnStdout: true, + script: "curl -so /dev/null -w '%{response_code}' ${url}" + ).trim() +} From 8bea9b40aa838c0294598678a674096265e93462 Mon Sep 17 00:00:00 2001 From: Oliver Nocon Date: Mon, 15 Oct 2018 14:18:47 +0200 Subject: [PATCH 2/5] add telemetry to healthExecuteCheck --- vars/healthExecuteCheck.groovy | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vars/healthExecuteCheck.groovy b/vars/healthExecuteCheck.groovy index 13d5ecf06..995da8715 100644 --- a/vars/healthExecuteCheck.groovy +++ b/vars/healthExecuteCheck.groovy @@ -1,10 +1,10 @@ import com.sap.piper.ConfigurationHelper - +import com.sap.piper.Utils import groovy.transform.Field @Field String STEP_NAME = 'healthExecuteCheck' @Field Set STEP_CONFIG_KEYS = [ - 'healthEndpoint', + 'healthEndpoint', 'testServerUrl' ] @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS @@ -22,6 +22,8 @@ void call(Map parameters = [:]) { .withMandatoryProperty('testServerUrl') .use() + new Utils().pushToSWA([step: STEP_NAME], config) + def checkUrl = config.testServerUrl if(config.healthEndpoint){ if(!checkUrl.endsWith('/')) From fe9dc7547b3406a6472bd9c82aab1fdaf073ac0a Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 15 Oct 2018 16:33:00 +0200 Subject: [PATCH 3/5] Bump version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 680a8c01c..c33f91185 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ 4.0.0 com.sap.cp.jenkins jenkins-library - 0.7 + 0.8 SAP CP Piper Library Shared library containing steps and utilities to set up continuous deployment processes for SAP technologies. From b83222726aea0db60cca4131414d0aa7e5389de4 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 17 Oct 2018 09:54:04 +0200 Subject: [PATCH 4/5] enhance commonPipelineEnvironment with additional information (#344) * enhance commonPipelineEnvironment with additional information add additional git information to `commonPipelineEnvironment`: * https url * git branch * github organization * github repository * rename github-related variables * rename github-related variables --- vars/commonPipelineEnvironment.groovy | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index b3e4ebfd9..7a78279e7 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -7,6 +7,12 @@ class commonPipelineEnvironment implements Serializable { //stores the gitCommitId as well as additional git information for the build during pipeline run String gitCommitId String gitSshUrl + String gitHttpsUrl + String gitBranch + + //GiutHub specific information + String githubOrg + String githubRepo //stores properties for a pipeline which build an artifact and then bundles it into a container private Map appContainerProperties = [:] @@ -30,6 +36,11 @@ class commonPipelineEnvironment implements Serializable { gitCommitId = null gitSshUrl = null + gitHttpsUrl = null + gitBranch = null + + githubOrg = null + githubRepo = null influxCustomData = [:] influxCustomDataMap = [pipeline_data: [:], step_data: [:]] From 7f7afdad1a049ad87161680eb673139574b21d04 Mon Sep 17 00:00:00 2001 From: Florian Wilhelm <2292245+fwilhe@users.noreply.github.com> Date: Wed, 17 Oct 2018 11:01:09 +0200 Subject: [PATCH 5/5] Implement workaround for incompatible change in cloud foundry API (#343) --- .../docs/steps/cloudFoundryDeploy.md | 12 ++++++-- src/com/sap/piper/CfManifestUtils.groovy | 26 ++++++++++++++++ test/groovy/CloudFoundryDeployTest.groovy | 25 +++++++++++++++- .../com/sap/piper/CfManifestUtilsTest.groovy | 30 +++++++++++++++++++ vars/cloudFoundryDeploy.groovy | 19 +++++++++++- 5 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 src/com/sap/piper/CfManifestUtils.groovy create mode 100644 test/groovy/com/sap/piper/CfManifestUtilsTest.groovy diff --git a/documentation/docs/steps/cloudFoundryDeploy.md b/documentation/docs/steps/cloudFoundryDeploy.md index f8c5cebf1..cb13fe4b9 100644 --- a/documentation/docs/steps/cloudFoundryDeploy.md +++ b/documentation/docs/steps/cloudFoundryDeploy.md @@ -61,6 +61,10 @@ Deployment can be done - cfOrg - cfSpace +!!! note + Due to [an incompatible change](https://github.com/cloudfoundry/cli/issues/1445) in the Cloud Foundry CLI, multiple buildpacks are not supported by this step. + If your `application` contains a list of `buildpacks` instead a single `buildpack`, this will be automatically re-written by the step when blue-green deployment is used. + * `deployTool` defines the tool which should be used for deployment. * `deployType` defines the type of deployment, either `standard` deployment which results in a system downtime or a zero-downtime `blue-green` deployment. * `dockerImage` defines the Docker image containing the deployment tools (like cf cli, ...) and `dockerWorkspace` defines the home directory of the default user of the `dockerImage` @@ -106,7 +110,11 @@ The following parameters can also be specified as step/stage/general parameters ## Example ```groovy -artifactSetVersion script: this, buildTool: 'maven' +cloudFoundryDeploy( + script: script, + deployType: 'blue-green', + cloudFoundry: [apiEndpoint: 'https://test.server.com', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', org: 'cfOrg', space: 'cfSpace'], + deployTool: 'cf_native' +) ``` - diff --git a/src/com/sap/piper/CfManifestUtils.groovy b/src/com/sap/piper/CfManifestUtils.groovy new file mode 100644 index 000000000..a65a3b2eb --- /dev/null +++ b/src/com/sap/piper/CfManifestUtils.groovy @@ -0,0 +1,26 @@ +package com.sap.piper + +import com.cloudbees.groovy.cps.NonCPS + +class CfManifestUtils { + @NonCPS + static Map transform(Map manifest) { + if (manifest.applications[0].buildpacks) { + manifest['applications'].each { Map application -> + def buildpacks = application['buildpacks'] + if (buildpacks) { + if (buildpacks instanceof List) { + if (buildpacks.size > 1) { + throw new RuntimeException('More than one Cloud Foundry Buildpack is not supported. Please check your manifest.yaml file.') + } + application['buildpack'] = buildpacks[0] + application.remove('buildpacks') + } else { + throw new RuntimeException('"buildpacks" in manifest.yaml is not a list. Please check your manifest.yaml file.') + } + } + } + } + return manifest + } +} diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index f4e5d7326..a22a76f50 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -105,6 +105,11 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeWithAppName() { + jryr.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) jsr.step.cloudFoundryDeploy([ script: nullScript, juStabUtils: utils, @@ -125,6 +130,11 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeWithAppNameCustomApi() { + jryr.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) jsr.step.cloudFoundryDeploy([ script: nullScript, juStabUtils: utils, @@ -142,6 +152,11 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeWithAppNameCompatible() { + jryr.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) jsr.step.cloudFoundryDeploy([ script: nullScript, juStabUtils: utils, @@ -165,7 +180,11 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeAppNameFromManifest() { helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) - jryr.registerYaml('test.yml', "[applications: [[name: 'manifestAppName']]]") + jryr.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) jsr.step.cloudFoundryDeploy([ script: nullScript, @@ -185,6 +204,10 @@ class CloudFoundryDeployTest extends BasePiperTest { void testCfNativeWithoutAppName() { helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) jryr.registerYaml('test.yml', "applications: [[]]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) thrown.expect(hudson.AbortException) thrown.expectMessage('[cloudFoundryDeploy] ERROR: No appName available in manifest test.yml.') diff --git a/test/groovy/com/sap/piper/CfManifestUtilsTest.groovy b/test/groovy/com/sap/piper/CfManifestUtilsTest.groovy new file mode 100644 index 000000000..f2feadc7a --- /dev/null +++ b/test/groovy/com/sap/piper/CfManifestUtilsTest.groovy @@ -0,0 +1,30 @@ +package com.sap.piper + +import org.junit.Test + +import static org.junit.Assert.* + +class CfManifestUtilsTest { + + @Test + void testManifestTransform() { + Map testFixture = [applications: [[buildpacks: ['sap_java_buildpack']]]] + Map expected = [applications: [[buildpack: 'sap_java_buildpack']]] + def actual = CfManifestUtils.transform(testFixture) + assertEquals(expected, actual) + } + + @Test(expected = RuntimeException) + void testManifestTransformMultipleBuildpacks() { + Map testFixture = [applications: [[buildpacks: ['sap_java_buildpack', 'another_buildpack']]]] + CfManifestUtils.transform(testFixture) + } + + @Test + void testManifestTransformShouldNotChange() { + Map testFixture = [applications: [[buildpack: 'sap_java_buildpack']]] + Map expected = [applications: [[buildpack: 'sap_java_buildpack']]] + def actual = CfManifestUtils.transform(testFixture) + assertEquals(expected, actual) + } +} diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index 811f13dd0..d4d27e709 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -1,5 +1,6 @@ import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper +import com.sap.piper.CfManifestUtils import groovy.transform.Field @@ -112,6 +113,7 @@ def deployCfNative (config) { def deployCommand = 'push' if (config.deployType == 'blue-green') { deployCommand = 'blue-green-deploy' + handleLegacyCfManifest(config) } else { config.smokeTest = '' } @@ -129,7 +131,7 @@ def deployCfNative (config) { } sh """#!/bin/bash - set +x + set +x export HOME=${config.dockerWorkspace} cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" cf plugins @@ -166,3 +168,18 @@ def deployMta (config) { sh "cf logout" } } + +def handleLegacyCfManifest(config) { + def manifest = readYaml file: config.cloudFoundry.manifest + String originalManifest = manifest.toString() + manifest = CfManifestUtils.transform(manifest) + String transformedManifest = manifest.toString() + if (originalManifest != transformedManifest) { + echo """The file ${config.cloudFoundry.manifest} is not compatible with the Cloud Foundry blue-green deployment plugin. Re-writing inline. +See this issue if you are interested in the background: https://github.com/cloudfoundry/cli/issues/1445.\n +Original manifest file content: $originalManifest\n +Transformed manifest file content: $transformedManifest""" + sh "rm ${config.cloudFoundry.manifest}" + writeYaml file: config.cloudFoundry.manifest, data: manifest + } +}