diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 675ff6177..1324fbf6d 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -512,6 +512,10 @@ steps: options: [] pullRequestProvider: 'GitHub' sonarScannerDownloadUrl: 'https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-3.3.0.1492-linux.zip' + spinnakerTriggerPipeline: + certFileCredentialsId: 'spinnaker-client-certificate' + keyFileCredentialsId: 'spinnaker-client-key' + timeout: 60 testsPublishResults: failOnError: false junit: diff --git a/test/groovy/SpinnakerTriggerPipelineTest.groovy b/test/groovy/SpinnakerTriggerPipelineTest.groovy new file mode 100644 index 000000000..4c23347b7 --- /dev/null +++ b/test/groovy/SpinnakerTriggerPipelineTest.groovy @@ -0,0 +1,202 @@ +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.rules.RuleChain +import util.* + +import static org.hamcrest.Matchers.* +import static org.junit.Assert.assertThat + +class SpinnakerTriggerPipelineTest extends BasePiperTest { + private ExpectedException exception = new ExpectedException().none() + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsLoggingRule logginRule = new JenkinsLoggingRule(this) + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsReadJsonRule readJsonRule = new JenkinsReadJsonRule(this) + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(new JenkinsReadYamlRule(this)) + .around(exception) + .around(shellRule) + .around(logginRule) + .around(readJsonRule) + .around(stepRule) + + class EnvMock { + def STAGE_NAME = 'testStage' + Map getEnvironment() { + return [key1: 'value1', key2: 'value2'] + } + } + + def credentialFileList = [] + def timeout = 0 + + @Before + void init() { + binding.setVariable('env', new EnvMock()) + + credentialFileList = [] + helper.registerAllowedMethod('file', [Map], { m -> + credentialFileList.add(m) + return m + }) + + Map credentialFileNames = [ + 'spinnaker-client-certificate': 'clientCert.file', + 'spinnaker-client-key': 'clientKey.file' + + ] + + helper.registerAllowedMethod('withCredentials', [List, Closure], { l, c -> + l.each { fileCredentials -> + binding.setProperty(fileCredentials.variable, credentialFileNames[fileCredentials.credentialsId]) + } + try { + c() + } finally { + l.each { fileCredentials -> + binding.setProperty(fileCredentials.variable, null) + } + } + }) + + helper.registerAllowedMethod('timeout', [Integer.class, Closure.class] , {i, body -> + timeout = i + return body() + }) + + //not sure where this comes from! + helper.registerAllowedMethod('waitUntil', [Closure.class], {body -> + List responseStatus = ['RUNNING', 'PAUSED', 'NOT_STARTED'] + while (!body()) { + //take another round with a different response status + responseStatus.each {status -> + shellRule.setReturnValue('curl -X GET https://spinnakerTest.url/testRef --silent --cert $clientCertificate --key $clientKey', "{\"status\": \"${status}\"}") + shellRule.setReturnValue('curl -X GET https://spinnakerTest.url/testRef --verbose --cert $clientCertificate --key $clientKey', "{\"status\": \"${status}\"}") + } + } + }) + + shellRule.setReturnValue('curl -H \'Content-Type: application/json\' -X POST -d \'{"parameters":{"param1":"val1"}}\' --silent --cert $clientCertificate --key $clientKey https://spinnakerTest.url/pipelines/spinnakerTestApp/spinnakerTestPipeline', '{"ref": "/testRef"}') + shellRule.setReturnValue('curl -H \'Content-Type: application/json\' -X POST -d \'{"parameters":{"param1":"val1"}}\' --verbose --cert $clientCertificate --key $clientKey https://spinnakerTest.url/pipelines/spinnakerTestApp/spinnakerTestPipeline', '{"ref": "/testRef"}') + shellRule.setReturnValue('curl -X GET https://spinnakerTest.url/testRef --silent --cert $clientCertificate --key $clientKey', '{"status": "SUCCEEDED"}') + shellRule.setReturnValue('curl -X GET https://spinnakerTest.url/testRef --verbose --cert $clientCertificate --key $clientKey', '{"status": "SUCCEEDED"}') + } + + @Test + void testDefaults() { + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + spinnakerGateUrl: 'https://spinnakerTest.url', + spinnakerApplication: 'spinnakerTestApp', + verbose: true + ], + stages: [ + testStage: [ + spinnakerPipeline: 'spinnakerTestPipeline', + pipelineParameters: [param1: 'val1'] + ] + ] + ] + stepRule.step.spinnakerTriggerPipeline( + script: nullScript + ) + + assertThat(timeout, is(60)) + + assertThat(logginRule.log, containsString('Triggering Spinnaker pipeline with parameters:')) + assertThat(logginRule.log, containsString('Spinnaker pipeline /testRef triggered, waiting for the pipeline to finish')) + assertThat(credentialFileList, + hasItem( + allOf( + hasEntry('credentialsId', 'spinnaker-client-key'), + hasEntry('variable', 'clientKey') + ) + ) + ) + assertThat(credentialFileList, + hasItem( + allOf( + hasEntry('credentialsId', 'spinnaker-client-certificate'), + hasEntry('variable', 'clientCertificate') + ) + ) + ) + } + + @Test + void testDisabledPipelineCheck() { + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + spinnaker: [ + gateUrl: 'https://spinnakerTest.url', + application: 'spinnakerTestApp' + ] + ], + stages: [ + testStage: [ + pipelineNameOrId: 'spinnakerTestPipeline', + pipelineParameters: [param1: 'val1'] + ] + ] + ] + stepRule.step.spinnakerTriggerPipeline( + script: nullScript, + timeout: 0 + ) + + assertThat(logginRule.log, containsString('Exiting without waiting for Spinnaker pipeline result.')) + assertThat(timeout, is(0)) + } + + @Test + void testTriggerFailure() { + + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + spinnakerGateUrl: 'https://spinnakerTest.url', + spinnakerApplication: 'spinnakerTestApp' + ], + stages: [ + testStage: [ + spinnakerPipeline: 'spinnakerTestPipeline' + ] + ] + ] + + shellRule.setReturnValue('curl -H \'Content-Type: application/json\' -X POST --silent --cert $clientCertificate --key $clientKey https://spinnakerTest.url/pipelines/spinnakerTestApp/spinnakerTestPipeline', '{}') + + exception.expectMessage('Failed to trigger Spinnaker pipeline') + stepRule.step.spinnakerTriggerPipeline( + script: nullScript, + ) + } + + @Test + void testPipelineFailure() { + + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + spinnakerGateUrl: 'https://spinnakerTest.url', + spinnakerApplication: 'spinnakerTestApp' + ], + stages: [ + testStage: [ + spinnakerPipeline: 'spinnakerTestPipeline', + pipelineParameters: [param1: 'val1'] + ] + ] + ] + + shellRule.setReturnValue('curl -X GET https://spinnakerTest.url/testRef --silent --cert $clientCertificate --key $clientKey', '{"status": "FAILED"}') + + exception.expectMessage('Spinnaker pipeline failed with FAILED') + stepRule.step.spinnakerTriggerPipeline( + script: nullScript, + ) + } +} diff --git a/vars/spinnakerTriggerPipeline.groovy b/vars/spinnakerTriggerPipeline.groovy new file mode 100644 index 000000000..3b215d933 --- /dev/null +++ b/vars/spinnakerTriggerPipeline.groovy @@ -0,0 +1,174 @@ +import com.cloudbees.groovy.cps.NonCPS +import com.sap.piper.JsonUtils +import groovy.text.GStringTemplateEngine + +import static com.sap.piper.Prerequisites.checkScript +import groovy.json.JsonOutput +import org.apache.commons.lang3.text.StrSubstitutor + + +import com.sap.piper.GenerateDocumentation +import com.sap.piper.ConfigurationHelper +import com.sap.piper.Utils + +import groovy.transform.Field + +@Field def STEP_NAME = getClass().getName() + +@Field Set GENERAL_CONFIG_KEYS = [ + 'spinnaker', + /** + * Whether verbose output should be produced. + * @possibleValues `true`, `false` + */ + 'verbose' +] +@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ + /** + * Defines the id of the file credentials in your Jenkins credentials store which contain the client certificate file for Spinnaker authentication. + * @parentConfigKey spinnaker + */ + 'certFileCredentialsId', + /** + * Defines the url of the Spinnaker Gateway Service as API endpoint for communication with Spinnaker. + * @parentConfigKey spinnaker + */ + 'gateUrl', + /** + * Defines the id of the file credentials in your Jenkins credentials store which contain the private key file for Spinnaker authentication. + * @parentConfigKey spinnaker + */ + 'keyFileCredentialsId', + /** + * Defines the name/id of the Spinnaker pipeline. + * @parentConfigKey spinnaker + */ + 'pipelineNameOrId', + /** + * Parameter map containing Spinnaker pipeline parameters. + * @parentConfigKey spinnaker + */ + 'pipelineParameters', + /** + * Defines the timeout in minutes for checking the Spinnaker pipeline result. + * By setting to `0` the check can be de-activated. + */ + 'timeout' + +]) +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +@Field Map CONFIG_KEY_COMPATIBILITY = [ + application: 'spinnakerApplication', + certFileCredentialsId: 'certCredentialId', + gateUrl: 'spinnakerGateUrl', + keyFileCredentialsId: 'keyCredentialId', + pipelineNameOrId: 'spinnakerPipeline', + pipelineParameters: 'pipelineParameters', + spinnaker: [ + application: 'application', + certFileCredentialsId: 'certFileCredentialsId', + keyFileCredentialsId: 'keyFileCredentialsId', + gateUrl: 'gateUrl', + pipelineParameters: 'pipelineParameters', + pipelineNameOrId: 'pipelineNameOrId' + ] +] + +/** + * Triggers a [Spinnaker](https://spinnaker.io) pipeline from a Jenkins pipeline. + * Spinnaker is for example used for Continuos Deployment scenarios to various Clouds. + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters) { + + final script = checkScript(this, parameters) ?: this + + // load default & individual configuration + Map config = ConfigurationHelper.newInstance(this) + .loadStepDefaults(CONFIG_KEY_COMPATIBILITY) + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS, CONFIG_KEY_COMPATIBILITY) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS, CONFIG_KEY_COMPATIBILITY) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS, CONFIG_KEY_COMPATIBILITY) + .mixin(parameters, PARAMETER_KEYS, CONFIG_KEY_COMPATIBILITY) + .withMandatoryProperty('spinnaker/gateUrl') + .withMandatoryProperty('spinnaker/application') + .withMandatoryProperty('spinnaker/pipelineNameOrId') + .use() + + // telemetry reporting + new Utils().pushToSWA([ + step: STEP_NAME + ], config) + + String paramsString = "" + if (config.spinnaker.pipelineParameters) { + def pipelineParameters = [parameters: config.spinnaker.pipelineParameters] + + paramsString = "-d '${new GStringTemplateEngine().createTemplate(JsonOutput.toJson(pipelineParameters)).make([config: config, env: env]).toString()}'" + + if (config.verbose) { + echo "[${STEP_NAME}] Triggering Spinnaker pipeline with parameters: ${paramsString}" + } + } + + def pipelineTriggerResponse + + //ToDO: support userId/pwd authentication or token authentication! + + def curlVerbosity = (config.verbose) ? '--verbose ' : '--silent ' + + withCredentials([ + file(credentialsId: config.spinnaker.keyFileCredentialsId, variable: 'clientKey'), + file(credentialsId: config.spinnaker.certFileCredentialsId, variable: 'clientCertificate') + ]) { + // Trigger a pipeline execution by calling invokePipelineConfigUsingPOST1 (see https://www.spinnaker.io/reference/api/docs.html) + pipelineTriggerResponse = sh(returnStdout: true, script: "curl -H 'Content-Type: application/json' -X POST ${paramsString} ${curlVerbosity} --cert \$clientCertificate --key \$clientKey ${config.spinnaker.gateUrl}/pipelines/${config.spinnaker.application}/${config.spinnaker.pipelineNameOrId}").trim() + } + if (config.verbose) { + echo "[${STEP_NAME}] Spinnaker pipeline trigger response = ${pipelineTriggerResponse}" + } + + def pipelineTriggerResponseObj = readJSON text: pipelineTriggerResponse + if (!pipelineTriggerResponseObj.ref) { + error "[${STEP_NAME}] Failed to trigger Spinnaker pipeline" + } + + if (config.timeout == 0) { + echo "[${STEP_NAME}] Exiting without waiting for Spinnaker pipeline result." + return + } + + echo "[${STEP_NAME}] Spinnaker pipeline ${pipelineTriggerResponseObj.ref} triggered, waiting for the pipeline to finish" + + def pipelineStatusResponseObj + timeout(config.timeout) { + waitUntil { + def pipelineStatusResponse + sleep 10 + withCredentials([ + file(credentialsId: config.spinnaker.keyFileCredentialsId, variable: 'clientKey'), + file(credentialsId: config.spinnaker.certFileCredentialsId, variable: 'clientCertificate') + ]) { + pipelineStatusResponse = sh returnStdout: true, script: "curl -X GET ${config.spinnaker.gateUrl}${pipelineTriggerResponseObj.ref} ${curlVerbosity} --cert \$clientCertificate --key \$clientKey" + } + pipelineStatusResponseObj = readJSON text: pipelineStatusResponse + echo "[${STEP_NAME}] Spinnaker pipeline ${pipelineTriggerResponseObj.ref} status: ${pipelineStatusResponseObj.status}" + + if (pipelineStatusResponseObj.status in ['RUNNING', 'PAUSED', 'NOT_STARTED']) { + return false + } else { + return true + } + } + } + if (pipelineStatusResponseObj.status != 'SUCCEEDED') { + if (config.verbose) { + echo "[${STEP_NAME}] Full Spinnaker response = ${new JsonUtils().groovyObjectToPrettyJsonString(pipelineStatusResponse)}" + } + error "[${STEP_NAME}] Spinnaker pipeline failed with ${pipelineStatusResponseObj.status}" + } + + } +}