From f6f1e0df5a666b65e96a089f1675e7bffde30558 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 26 Jun 2019 17:05:11 +0200 Subject: [PATCH 001/141] fileExists also with map --- test/groovy/util/JenkinsFileExistsRule.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/test/groovy/util/JenkinsFileExistsRule.groovy b/test/groovy/util/JenkinsFileExistsRule.groovy index 024ab02ff..9f88c38a0 100644 --- a/test/groovy/util/JenkinsFileExistsRule.groovy +++ b/test/groovy/util/JenkinsFileExistsRule.groovy @@ -26,6 +26,7 @@ class JenkinsFileExistsRule implements TestRule { void evaluate() throws Throwable { testInstance.helper.registerAllowedMethod('fileExists', [String.class], {s -> return s in existingFiles}) + testInstance.helper.registerAllowedMethod('fileExists', [Map.class], {m -> return m.file in existingFiles}) base.evaluate() } From 5c3307d71bfd2d04065210785633e7d9505f94ba Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 26 Jun 2019 17:05:39 +0200 Subject: [PATCH 002/141] Add hasSnippet --- test/groovy/util/CommandLineMatcher.groovy | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/groovy/util/CommandLineMatcher.groovy b/test/groovy/util/CommandLineMatcher.groovy index 5a778b0b7..734a5acc0 100644 --- a/test/groovy/util/CommandLineMatcher.groovy +++ b/test/groovy/util/CommandLineMatcher.groovy @@ -31,6 +31,11 @@ class CommandLineMatcher extends BaseMatcher { return this } + CommandLineMatcher hasSnippet(String snippet) { + this.args.add(snippet) + return this + } + CommandLineMatcher hasArgument(String arg) { this.args.add(arg) return this @@ -58,7 +63,7 @@ class CommandLineMatcher extends BaseMatcher { for (String arg : args) { if (!cmd.matches(/.*[\s]*${arg}[\s]*.*/)) { - hint = "A command line having argument '${arg}'." + hint = "A command line having argument/snippet '${arg}'." matches = false } } From 0e990446fce00517fd796058d536249309e93f99 Mon Sep 17 00:00:00 2001 From: Andre Pany Date: Wed, 3 Jul 2019 12:20:03 +0200 Subject: [PATCH 003/141] whitesourceExecuteScan: Add scanType dub --- resources/default_pipeline_environment.yml | 5 +++ .../WhitesourceConfigurationHelper.groovy | 5 +++ test/groovy/WhitesourceExecuteScanTest.groovy | 41 +++++++++++++++++++ .../WhitesourceConfigurationHelperTest.groovy | 5 +-- vars/whitesourceExecuteScan.groovy | 3 +- 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 3122765ab..f8b243a45 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -395,6 +395,11 @@ steps: && mkdir -p \$GOPATH/src/${config.whitesource.projectName.substring(0, config.whitesource.projectName.lastIndexOf('/'))} && ln -s \$(pwd) \$GOPATH/src/${config.whitesource.projectName} && cd \$GOPATH/src/${config.whitesource.projectName} && dep ensure + dub: + buildDescriptorFile: './dub.json' + stashContent: + - 'buildDescriptor' + - 'checkmarx' sbt: buildDescriptorFile: './build.sbt' dockerImage: 'hseeberger/scala-sbt:8u181_2.12.8_1.2.8' diff --git a/src/com/sap/piper/WhitesourceConfigurationHelper.groovy b/src/com/sap/piper/WhitesourceConfigurationHelper.groovy index bdd5607b7..db2d63a79 100644 --- a/src/com/sap/piper/WhitesourceConfigurationHelper.groovy +++ b/src/com/sap/piper/WhitesourceConfigurationHelper.groovy @@ -79,6 +79,11 @@ class WhitesourceConfigurationHelper implements Serializable { [name: 'excludes', value: '**/*sources.jar **/*javadoc.jar'] ] break + case 'dub': + mapping += [ + [name: 'includes', value: '**/*.d **/*.di'] + ] + break default: script.echo "[Warning][Whitesource] Configuration for scanType: '${config.scanType}' is not yet hardened, please do a quality assessment of your scan results." } diff --git a/test/groovy/WhitesourceExecuteScanTest.groovy b/test/groovy/WhitesourceExecuteScanTest.groovy index 1aa3bc5c5..ad5f5bdc8 100644 --- a/test/groovy/WhitesourceExecuteScanTest.groovy +++ b/test/groovy/WhitesourceExecuteScanTest.groovy @@ -88,6 +88,7 @@ class WhitesourceExecuteScanTest extends BasePiperTest { helper.registerAllowedMethod( "getSbtGAV", [String], {return [group: 'com.sap.sbt', artifact: 'test-scala', version: '1.2.3']}) helper.registerAllowedMethod( "getPipGAV", [String], {return [artifact: 'test-python', version: '1.2.3']}) helper.registerAllowedMethod( "getMavenGAV", [String], {return [group: 'com.sap.maven', artifact: 'test-java', version: '1.2.3']}) + helper.registerAllowedMethod( "getDubGAV", [String], {return [group: 'com.sap.dlang', artifact: 'test-dub', version: '1.2.3']}) nullScript.commonPipelineEnvironment.configuration = nullScript.commonPipelineEnvironment.configuration ?: [:] nullScript.commonPipelineEnvironment.configuration['steps'] = nullScript.commonPipelineEnvironment.configuration['steps'] ?: [:] @@ -404,6 +405,46 @@ class WhitesourceExecuteScanTest extends BasePiperTest { assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('projectName=com.sap.sbt.test-scala')) } + @Test + void testDub() { + + helper.registerAllowedMethod("readProperties", [Map], { + def result = new Properties() + result.putAll([ + "apiKey": "b39d1328-52e2-42e3-98f0-932709daf3f0", + "productName": "SHC - Piper", + "checkPolicies": "true", + "projectName": "python-test", + "projectVersion": "2.0.0" + ]) + return result + }) + + stepRule.step.whitesourceExecuteScan([ + script : nullScript, + whitesourceRepositoryStub : whitesourceStub, + whitesourceOrgAdminRepositoryStub : whitesourceOrgAdminRepositoryStub, + descriptorUtilsStub : descriptorUtilsStub, + scanType : 'dub', + juStabUtils : utils, + productName : 'testProductName', + orgToken : 'testOrgToken', + reporting : false + ]) + + assertThat(loggingRule.log, containsString('Unstash content: buildDescriptor')) + assertThat(loggingRule.log, containsString('Unstash content: checkmarx')) + + assertThat(shellRule.shell, Matchers.hasItems( + is('curl --location --output wss-unified-agent.jar https://github.com/whitesource/unified-agent-distribution/raw/master/standAlone/wss-unified-agent.jar'), + is('./bin/java -jar wss-unified-agent.jar -c \'./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3\' -apiKey \'testOrgToken\' -userKey \'token-0815\' -product \'testProductName\'') + )) + + assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('apiKey=testOrgToken')) + assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('productName=testProductName')) + assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('userKey=token-0815')) + } + @Test void testGo() { nullScript.commonPipelineEnvironment.gitHttpsUrl = 'https://github.wdf.sap.corp/test/golang' diff --git a/test/groovy/com/sap/piper/WhitesourceConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/WhitesourceConfigurationHelperTest.groovy index a48d44fdc..3547a33b8 100644 --- a/test/groovy/com/sap/piper/WhitesourceConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/WhitesourceConfigurationHelperTest.groovy @@ -122,11 +122,10 @@ class WhitesourceConfigurationHelperTest extends BasePiperTest { containsString("apiKey=abcd"), containsString("productName=DIST - name1"), containsString("productToken=1234"), - containsString("userKey=0000") + containsString("userKey=0000"), + containsString("includes=**/*.d **/*.di") ) ) - - assertThat(jlr.log, containsString("[Whitesource] Configuration for scanType: 'dub' is not yet hardened, please do a quality assessment of your scan results.")) } @Test diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index 5434dcaef..c7c0cfd21 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -63,7 +63,7 @@ import static com.sap.piper.Prerequisites.checkScript 'userTokenCredentialsId', /** * Type of development stack used to implement the solution. - * @possibleValues `golang`, `maven`, `mta`, `npm`, `pip`, `sbt` + * @possibleValues `golang`, `maven`, `mta`, `npm`, `pip`, `sbt`, `dub` */ 'scanType', /** @@ -436,6 +436,7 @@ private resolveProjectIdentifiers(script, descriptorUtils, config) { gav = descriptorUtils.getGoGAV(config.buildDescriptorFile, new URI(script.commonPipelineEnvironment.getGitHttpsUrl())) break case 'dub': + gav = descriptorUtils.getDubGAV(config.buildDescriptorFile) break case 'maven': gav = descriptorUtils.getMavenGAV(config.buildDescriptorFile) From 2d84095b2fabd363869a56fcbb04ebb2580991ae Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Thu, 11 Jul 2019 11:33:34 +0200 Subject: [PATCH 004/141] Update WhitesourceExecuteScanTest.groovy --- test/groovy/WhitesourceExecuteScanTest.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/groovy/WhitesourceExecuteScanTest.groovy b/test/groovy/WhitesourceExecuteScanTest.groovy index da1e78b0f..59e440692 100644 --- a/test/groovy/WhitesourceExecuteScanTest.groovy +++ b/test/groovy/WhitesourceExecuteScanTest.groovy @@ -436,7 +436,7 @@ class WhitesourceExecuteScanTest extends BasePiperTest { assertThat(loggingRule.log, containsString('Unstash content: checkmarx')) assertThat(shellRule.shell, Matchers.hasItems( - is('curl --location --output wss-unified-agent.jar https://github.com/whitesource/unified-agent-distribution/raw/master/standAlone/wss-unified-agent.jar'), + is('curl --location --output wss-unified-agent.jar https://github.com/whitesource/unified-agent-distribution/releases/latest/download/wss-unified-agent.jar'), is('./bin/java -jar wss-unified-agent.jar -c \'./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3\' -apiKey \'testOrgToken\' -userKey \'token-0815\' -product \'testProductName\'') )) From 31b9874eff345781cd5381b95ac06ff81a7f025d Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Tue, 16 Jul 2019 20:31:46 +0200 Subject: [PATCH 005/141] githubPublishRelease - properly handle situation where no release exists yet (#792) correct error handling to properly take care of non-existing initial release. --- test/groovy/GithubPublishReleaseTest.groovy | 33 +++++++++++++++++---- vars/githubPublishRelease.groovy | 2 +- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/test/groovy/GithubPublishReleaseTest.groovy b/test/groovy/GithubPublishReleaseTest.groovy index b37a96eb2..5ed463e6c 100644 --- a/test/groovy/GithubPublishReleaseTest.groovy +++ b/test/groovy/GithubPublishReleaseTest.groovy @@ -33,8 +33,13 @@ class GithubPublishReleaseTest extends BasePiperTest { def data def requestList = [] + def responseStatusLatestRelease + @Before void init() throws Exception { + + responseStatusLatestRelease = 200 + // register Jenkins commands with mock values helper.registerAllowedMethod( "deleteDir", [], null ) helper.registerAllowedMethod("httpRequest", [], null) @@ -57,12 +62,9 @@ class GithubPublishReleaseTest extends BasePiperTest { def responseRelease = '{"url":"https://api.github.com/SAP/jenkins-library/releases/27149","assets_url":"https://api.github.com/SAP/jenkins-library/releases/27149/assets","upload_url":"https://github.com/api/uploads/repos/ContinuousDelivery/piper-library/releases/27149/assets{?name,label}","html_url":"https://github.com/ContinuousDelivery/piper-library/releases/tag/test","id":27149,"tag_name":"test","target_commitish":"master","name":"v1.0.0","draft":false,"author":{"login":"XTEST2","id":6991,"avatar_url":"https://github.com/avatars/u/6991?","gravatar_id":"","url":"https://api.github.com/users/XTEST2","html_url":"https://github.com/XTEST2","followers_url":"https://api.github.com/users/XTEST2/followers","following_url":"https://api.github.com/users/XTEST2/following{/other_user}","gists_url":"https://api.github.com/users/XTEST2/gists{/gist_id}","starred_url":"https://api.github.com/users/XTEST2/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/XTEST2/subscriptions","organizations_url":"https://api.github.com/users/XTEST2/orgs","repos_url":"https://api.github.com/users/XTEST2/repos","events_url":"https://api.github.com/users/XTEST2/events{/privacy}","received_events_url":"https://api.github.com/users/XTEST2/received_events","type":"User","site_admin":false},"prerelease":false,"created_at":"2018-04-18T11:00:17Z","published_at":"2018-04-18T11:32:34Z","assets":[],"tarball_url":"https://api.github.com/SAP/jenkins-library/tarball/test","zipball_url":"https://api.github.com/SAP/jenkins-library/zipball/test","body":"Description of the release"}' helper.registerAllowedMethod("httpRequest", [String.class], { s -> - def result = [status: 404] + def result = [:] requestList.push(s.toString()) - if(s.contains('/releases/latest?')) { - result.content = responseLatestRelease - result.status = 200 - } else if(s.contains('/issues?')) { + if(s.contains('/issues?')) { result.content = responseIssues result.status = 200 } @@ -70,12 +72,16 @@ class GithubPublishReleaseTest extends BasePiperTest { }) helper.registerAllowedMethod("httpRequest", [Map.class], { m -> def result = '' + def status = 200 requestList.push(m?.url?.toString()) if(m?.url?.contains('/releases?')){ data = new JsonSlurperClassic().parseText(m?.requestBody?.toString()) result = responseRelease + } else if(m.url.contains('/releases/latest?')) { + result = responseLatestRelease + status = responseStatusLatestRelease } - return [content: result] + return [content: result, status: status] }) } @@ -146,6 +152,21 @@ class GithubPublishReleaseTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testNoReleaseYet() { + responseStatusLatestRelease = 404 + + stepRule.step.githubPublishRelease( + script: nullScript, + githubOrg: 'TestOrg', + githubRepo: 'TestRepo', + githubTokenCredentialsId: 'TestCredentials', + version: '1.2.3' + ) + + assertThat(loggingRule.log, containsString('This is the first release - no previous releases available')) + } + @Test void testExcludeLabels() throws Exception { stepRule.step.githubPublishRelease( diff --git a/vars/githubPublishRelease.groovy b/vars/githubPublishRelease.groovy index bac7850e0..cbda38efc 100644 --- a/vars/githubPublishRelease.groovy +++ b/vars/githubPublishRelease.groovy @@ -98,7 +98,7 @@ void call(Map parameters = [:]) { Map getLastRelease(config, TOKEN){ def result = [:] - def response = httpRequest "${config.githubApiUrl}/repos/${config.githubOrg}/${config.githubRepo}/releases/latest?access_token=${TOKEN}" + def response = httpRequest url: "${config.githubApiUrl}/repos/${config.githubOrg}/${config.githubRepo}/releases/latest?access_token=${TOKEN}", validResponseCodes: '100:500' if (response.status == 200) { result = readJSON text: response.content } else { From 1d5b08f0571ae08385b7c3bd1c08611c8b9706b0 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Wed, 17 Jul 2019 09:25:30 +0200 Subject: [PATCH 006/141] Update default_pipeline_environment.yml --- resources/default_pipeline_environment.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index b3cbcd7f5..51cdf668a 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -397,6 +397,8 @@ steps: && cd \$GOPATH/src/${config.whitesource.projectName} && dep ensure dub: buildDescriptorFile: './dub.json' + dockerImage: 'buildpack-deps:stretch-curl' + dockerWorkspace: '/home/dub' stashContent: - 'buildDescriptor' - 'checkmarx' From be33eccbec684ae7a3aec78369c27d4542e195e9 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 17 Jul 2019 12:01:24 +0200 Subject: [PATCH 007/141] Take proper jnlp image as default for Kubernetes execution (#759) * Take proper jnlp image as default for Kubernetes execution Following changes are contained: * removal of custom jnlp image as default * allow customization of jnlp image via system environment fixes #757 * add documentation --- resources/default_pipeline_environment.yml | 1 - .../DockerExecuteOnKubernetesTest.groovy | 50 ++++++++++++++++++- vars/dockerExecuteOnKubernetes.groovy | 24 +++++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index ca249b077..f8910e16a 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -38,7 +38,6 @@ general: githubServerUrl: 'https://github.com' gitSshKeyCredentialsId: '' #needed to allow sshagent to run with local ssh key jenkinsKubernetes: - jnlpAgent: 's4sdk/jenkins-agent-k8s:latest' securityContext: # Setting security context globally is currently not working with jaas # runAsUser: 1000 diff --git a/test/groovy/DockerExecuteOnKubernetesTest.groovy b/test/groovy/DockerExecuteOnKubernetesTest.groovy index 06f91332c..af94602c0 100644 --- a/test/groovy/DockerExecuteOnKubernetesTest.groovy +++ b/test/groovy/DockerExecuteOnKubernetesTest.groovy @@ -261,12 +261,10 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { assertThat(containerName, is('mavenexecute')) assertThat(containersList, allOf( - hasItem('jnlp'), hasItem('mavenexecute'), hasItem('selenium'), )) assertThat(imageList, allOf( - hasItem('s4sdk/jenkins-agent-k8s:latest'), hasItem('maven:3.5-jdk-8-alpine'), hasItem('selenium/standalone-chrome'), )) @@ -388,6 +386,54 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { assertThat(podNodeSelector, is('size:big')) } + @Test + void testDockerExecuteOnKubernetesCustomJnlpViaEnv() { + + nullScript.configuration = [ + general: [jenkinsKubernetes: [jnlpAgent: 'config/jnlp:latest']] + ] + binding.variables.env.JENKINS_JNLP_IMAGE = 'env/jnlp:latest' + stepRule.step.dockerExecuteOnKubernetes( + script: nullScript, + juStabUtils: utils, + dockerImage: 'maven:3.5-jdk-8-alpine', + ) { bodyExecuted = true } + assertTrue(bodyExecuted) + + assertThat(containersList, allOf( + hasItem('jnlp'), + hasItem('container-exec') + )) + assertThat(imageList, allOf( + hasItem('env/jnlp:latest'), + hasItem('maven:3.5-jdk-8-alpine'), + )) + } + + @Test + void testDockerExecuteOnKubernetesCustomJnlpViaConfig() { + + nullScript.configuration = [ + general: [jenkinsKubernetes: [jnlpAgent: 'config/jnlp:latest']] + ] + binding.variables.env.JENKINS_JNLP_IMAGE = 'config/jnlp:latest' + stepRule.step.dockerExecuteOnKubernetes( + script: nullScript, + juStabUtils: utils, + dockerImage: 'maven:3.5-jdk-8-alpine', + ) { bodyExecuted = true } + assertTrue(bodyExecuted) + + assertThat(containersList, allOf( + hasItem('jnlp'), + hasItem('container-exec') + )) + assertThat(imageList, allOf( + hasItem('config/jnlp:latest'), + hasItem('maven:3.5-jdk-8-alpine'), + )) + } + private container(options, body) { containerName = options.name diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index f1a23a89e..c6ef682a7 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -106,6 +106,15 @@ import hudson.AbortException /** * Executes a closure inside a container in a kubernetes pod. * Proxy environment variables defined on the Jenkins machine are also available in the container. + * + * By default jnlp agent defined for kubernetes-plugin will be used (see https://github.com/jenkinsci/kubernetes-plugin#pipeline-support). + * + * It is possible to define a custom jnlp agent image by + * + * 1. Defining the jnlp image via environment variable JENKINS_JNLP_IMAGE in the Kubernetes landscape + * 2. Defining the image via config (`jenkinsKubernetes.jnlpAgent`) + * + * Option 1 will take precedence over option 2. */ @GenerateDocumentation void call(Map parameters = [:], body) { @@ -262,10 +271,17 @@ private void unstashWorkspace(config, prefix) { } private List getContainerList(config) { - def result = [[ - name: 'jnlp', - image: config.jenkinsKubernetes.jnlpAgent - ]] + + //If no custom jnlp agent provided as default jnlp agent (jenkins/jnlp-slave) as defined in the plugin, see https://github.com/jenkinsci/kubernetes-plugin#pipeline-support + def result = [] + + //allow definition of jnlp image via environment variable JENKINS_JNLP_IMAGE in the Kubernetes landscape or via config as fallback + if (env.JENKINS_JNLP_IMAGE || config.jenkinsKubernetes.jnlpAgent) { + result.push([ + name: 'jnlp', + image: env.JENKINS_JNLP_IMAGE ?: config.jenkinsKubernetes.jnlpAgent + ]) + } config.containerMap.each { imageName, containerName -> def containerPullImage = config.containerPullImageFlags?.get(imageName) def containerSpec = [ From 5bf7cda940abf1b555e905e8ab486b46fa33b15a Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Thu, 18 Jul 2019 12:09:54 +0200 Subject: [PATCH 008/141] add new step piperPublishNotifications (#652) * add new step for notification publication * add test cases * add helper method * correct import * Update pom.xml * add step to post section * add step piperPublishNotifications * move step to end of pipeline to gather all findings * use handlePipelineStepErrors step * use commonPipelineEnvironment * correct reporting * add configuration * fix typos * fix rule setup * remove test scope * add method to fetch full build log * add methods for warnings-ng parser creation * remove warnings plugin coding * add default parser settings * change parameter handling for parser creation * adapt step * fix parser creation * use ParserConfig.contains * use correct parameter name * correct parser regex * change issue creation * use classloader * fix typo * Revert "fix typo" This reverts commit 446a201ae44ae0bf6bcfedd17e583183ceaabfaa. * Revert "use classloader" This reverts commit a89648703239f8cf3da465bfa570500608dc14f2. * rename step to piperPublishWarnings * extract recordIssuesSettings to defaults * make addWarningsNGParser non-static * remove node * adjust test case * add docs * rename log file * fix tests * fix typos * rename parameter * add import for IOUtils * check plugin activation * add comment for class loader usage --- .../docs/steps/piperPublishWarnings.md | 9 ++ documentation/mkdocs.yml | 1 + resources/default_pipeline_environment.yml | 8 ++ src/com/sap/piper/JenkinsUtils.groovy | 34 ++++++- test/groovy/PiperPublishWarningsTest.groovy | 85 ++++++++++++++++ .../PiperPipelineStagePostTest.groovy | 7 +- vars/piperPipelineStagePost.groovy | 1 + vars/piperPublishWarnings.groovy | 96 +++++++++++++++++++ 8 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 documentation/docs/steps/piperPublishWarnings.md create mode 100644 test/groovy/PiperPublishWarningsTest.groovy create mode 100644 vars/piperPublishWarnings.groovy diff --git a/documentation/docs/steps/piperPublishWarnings.md b/documentation/docs/steps/piperPublishWarnings.md new file mode 100644 index 000000000..3d8f3171a --- /dev/null +++ b/documentation/docs/steps/piperPublishWarnings.md @@ -0,0 +1,9 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 08ee06987..552328a21 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -37,6 +37,7 @@ nav: - pipelineStashFiles: steps/pipelineStashFiles.md - pipelineStashFilesAfterBuild: steps/pipelineStashFilesAfterBuild.md - pipelineStashFilesBeforeBuild: steps/pipelineStashFilesBeforeBuild.md + - piperPublishWarnings: steps/piperPublishWarnings.md - prepareDefaultValues: steps/prepareDefaultValues.md - seleniumExecuteTests: steps/seleniumExecuteTests.md - setupCommonPipelineEnvironment: steps/setupCommonPipelineEnvironment.md diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index f8910e16a..4c32565f2 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -443,6 +443,14 @@ steps: tests: '' noDefaultExludes: - 'git' + piperPublishWarnings: + parserId: piper + parserName: Piper + parserPattern: '\[(INFO|WARNING|ERROR)\] (.*) \(([^) ]*)\/([^) ]*)\)' + parserScript: 'return builder.guessSeverity(matcher.group(1)).setMessage(matcher.group(2)).setModuleName(matcher.group(3)).setType(matcher.group(4)).buildOptional()' + recordIssuesSettings: + blameDisabled: true + enabledForFailure: true seleniumExecuteTests: buildTool: 'npm' containerPortMappings: diff --git a/src/com/sap/piper/JenkinsUtils.groovy b/src/com/sap/piper/JenkinsUtils.groovy index 28933ccba..c54a87818 100644 --- a/src/com/sap/piper/JenkinsUtils.groovy +++ b/src/com/sap/piper/JenkinsUtils.groovy @@ -1,10 +1,14 @@ package com.sap.piper import com.cloudbees.groovy.cps.NonCPS -import jenkins.model.Jenkins -import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException + import hudson.tasks.junit.TestResultAction +import jenkins.model.Jenkins + +import org.apache.commons.io.IOUtils +import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException + @API @NonCPS static def isPluginActive(pluginId) { @@ -19,6 +23,32 @@ static boolean hasTestFailures(build){ return action && action.getFailCount() != 0 } +boolean addWarningsNGParser(String id, String name, String regex, String script, String example = ''){ + def classLoader = this.getClass().getClassLoader() + // usage of class loader to avoid plugin dependency for other use cases of JenkinsUtils class + def parserConfig = classLoader.loadClass('io.jenkins.plugins.analysis.warnings.groovy.ParserConfiguration', true)?.getInstance() + + if(parserConfig.contains(id)){ + return false + }else{ + parserConfig.setParsers( + parserConfig.getParsers().plus( + classLoader.loadClass('io.jenkins.plugins.analysis.warnings.groovy.GroovyParser', true)?.newInstance(id, name, regex, script, example) + ) + ) + return true + } +} + +@NonCPS +static String getFullBuildLog(currentBuild) { + Reader reader = currentBuild.getRawBuild().getLogReader() + String logContent = IOUtils.toString(reader); + reader.close(); + reader = null + return logContent +} + def nodeAvailable() { try { sh "echo 'Node is available!'" diff --git a/test/groovy/PiperPublishWarningsTest.groovy b/test/groovy/PiperPublishWarningsTest.groovy new file mode 100644 index 000000000..1114c4c6f --- /dev/null +++ b/test/groovy/PiperPublishWarningsTest.groovy @@ -0,0 +1,85 @@ +#!groovy +package steps + +import com.sap.piper.JenkinsUtils + +import static org.hamcrest.Matchers.allOf +import static org.hamcrest.Matchers.any +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.hasItem +import static org.hamcrest.Matchers.hasEntry +import static org.hamcrest.Matchers.hasKey + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import static org.junit.Assert.assertThat + +import util.BasePiperTest +import util.Rules +import util.JenkinsLoggingRule +import util.JenkinsReadYamlRule +import util.JenkinsStepRule +import util.JenkinsShellCallRule + +import static com.lesfurets.jenkins.unit.MethodSignature.method + +class PiperPublishWarningsTest extends BasePiperTest { + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + + def warningsParserSettings + def groovyScriptParserSettings + def warningsPluginOptions + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(new JenkinsReadYamlRule(this)) + .around(loggingRule) + .around(shellRule) + .around(stepRule) + + @Before + void init() throws Exception { + warningsParserSettings = [:] + groovyScriptParserSettings = [:] + warningsPluginOptions = [:] + + // add handler for generic step call + helper.registerAllowedMethod("writeFile", [Map.class], null) + helper.registerAllowedMethod("recordIssues", [Map.class], { + parameters -> warningsPluginOptions = parameters + }) + helper.registerAllowedMethod("groovyScript", [Map.class], { + parameters -> groovyScriptParserSettings = parameters + }) + JenkinsUtils.metaClass.addWarningsNGParser = { String s1, String s2, String s3, String s4 -> + warningsParserSettings = [id: s1, name: s2, regex: s3, script: s4] + return true + } + JenkinsUtils.metaClass.static.getFullBuildLog = { def currentBuild -> return ""} + JenkinsUtils.metaClass.static.isPluginActive = { id -> return true} + } + + @Test + void testPublishWarnings() throws Exception { + stepRule.step.piperPublishWarnings(script: nullScript) + // asserts + assertThat(loggingRule.log, containsString('[piperPublishWarnings] Added warnings-ng plugin parser \'Piper\' configuration.')) + assertThat(warningsParserSettings, hasEntry('id', 'piper')) + assertThat(warningsParserSettings, hasEntry('name', 'Piper')) + assertThat(warningsParserSettings, hasEntry('regex', '\\[(INFO|WARNING|ERROR)\\] (.*) \\(([^) ]*)\\/([^) ]*)\\)')) + assertThat(warningsParserSettings, hasKey('script')) + assertThat(warningsPluginOptions, allOf( + hasEntry('enabledForFailure', true), + hasEntry('blameDisabled', true) + )) + assertThat(warningsPluginOptions, hasKey('tools')) + + assertThat(groovyScriptParserSettings, hasEntry('parserId', 'piper')) + assertThat(groovyScriptParserSettings, hasEntry('pattern', 'build.log')) + } +} diff --git a/test/groovy/templates/PiperPipelineStagePostTest.groovy b/test/groovy/templates/PiperPipelineStagePostTest.groovy index 716a8e331..904967595 100644 --- a/test/groovy/templates/PiperPipelineStagePostTest.groovy +++ b/test/groovy/templates/PiperPipelineStagePostTest.groovy @@ -37,13 +37,14 @@ class PiperPipelineStagePostTest extends BasePiperTest { helper.registerAllowedMethod('influxWriteData', [Map.class], {m -> stepsCalled.add('influxWriteData')}) helper.registerAllowedMethod('slackSendNotification', [Map.class], {m -> stepsCalled.add('slackSendNotification')}) helper.registerAllowedMethod('mailSendNotification', [Map.class], {m -> stepsCalled.add('mailSendNotification')}) + helper.registerAllowedMethod('piperPublishWarnings', [Map.class], {m -> stepsCalled.add('piperPublishWarnings')}) } @Test void testPostDefault() { jsr.step.piperPipelineStagePost(script: nullScript, juStabUtils: utils) - assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification')) + assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification','piperPublishWarnings')) assertThat(stepsCalled, not(hasItems('slackSendNotification'))) } @@ -53,7 +54,7 @@ class PiperPipelineStagePostTest extends BasePiperTest { jsr.step.piperPipelineStagePost(script: nullScript, juStabUtils: utils) - assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification')) + assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification','piperPublishWarnings')) assertThat(stepsCalled, not(hasItems('slackSendNotification'))) } @@ -63,6 +64,6 @@ class PiperPipelineStagePostTest extends BasePiperTest { jsr.step.piperPipelineStagePost(script: nullScript, juStabUtils: utils) - assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification','slackSendNotification')) + assertThat(stepsCalled, hasItems('influxWriteData','mailSendNotification','slackSendNotification','piperPublishWarnings')) } } diff --git a/vars/piperPipelineStagePost.groovy b/vars/piperPipelineStagePost.groovy index 36515cb6e..fe6950fa8 100644 --- a/vars/piperPipelineStagePost.groovy +++ b/vars/piperPipelineStagePost.groovy @@ -48,5 +48,6 @@ void call(Map parameters = [:]) { } } mailSendNotification script: script + piperPublishWarnings script: script } } diff --git a/vars/piperPublishWarnings.groovy b/vars/piperPublishWarnings.groovy new file mode 100644 index 000000000..25279b1f6 --- /dev/null +++ b/vars/piperPublishWarnings.groovy @@ -0,0 +1,96 @@ +import static com.sap.piper.Prerequisites.checkScript + +import com.sap.piper.ConfigurationHelper +import com.sap.piper.GenerateDocumentation +import com.sap.piper.JenkinsUtils +import com.sap.piper.Utils + +import groovy.transform.Field + +@Field String STEP_NAME = getClass().getName() +@Field List PLUGIN_ID_LIST = ['warnings-ng'] + +@Field Set GENERAL_CONFIG_KEYS = [] +@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ + /** + * The id of the Groovy script parser. If the id is not present in the current Jenkins configuration it is created. + */ + 'parserId', + /** + * The display name for the warnings parsed by the parser. + * Only considered if a new parser is created. + */ + 'parserName', + /** + * The pattern used to parse the log file. + * Only considered if a new parser is created. + */ + 'parserPattern', + /** + * The script used to parse the matches produced by the pattern into issues. + * Only considered if a new parser is created. + * see https://github.com/jenkinsci/analysis-model/blob/master/src/main/java/edu/hm/hafner/analysis/IssueBuilder.java + */ + 'parserScript', + /** + * Settings that are passed to the recordIssues step of the warnings-ng plugin. + * see https://github.com/jenkinsci/warnings-ng-plugin/blob/master/doc/Documentation.md#configuration + */ + 'recordIssuesSettings' +]) +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS.plus([]) + +/** + * This step scans the current build log for messages produces by the Piper library steps and publishes them on the Jenkins job run as *Piper warnings* via the warnings-ng plugin. + * + * The default parser detects log entries with the following pattern: `[] (/)` + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters, allowBuildFailure: true) { + + final script = checkScript(this, parameters) ?: this + + for(String id : PLUGIN_ID_LIST){ + if (!JenkinsUtils.isPluginActive(id)) { + error("[ERROR][${STEP_NAME}] The step requires the plugin '${id}' to be installed and activated in the Jenkins.") + } + } + + // load default & individual configuration + Map configuration = ConfigurationHelper.newInstance(this) + .loadStepDefaults() + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName ?: env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixin(parameters, PARAMETER_KEYS) + .use() + // report to SWA + new Utils().pushToSWA([ + step: STEP_NAME, + stepParamKey1: 'scriptMissing', + stepParam1: parameters?.script == null + ], configuration) + + // add Piper Notifications parser to config if missing + if(new JenkinsUtils().addWarningsNGParser( + configuration.parserId, + configuration.parserName, + configuration.parserPattern, + configuration.parserScript + )){ + echo "[${STEP_NAME}] Added warnings-ng plugin parser '${configuration.parserName}' configuration." + } + + writeFile file: 'build.log', text: JenkinsUtils.getFullBuildLog(script.currentBuild) + // parse log for Piper Notifications + recordIssues( + configuration.recordIssuesSettings.plus([ + tools: [groovyScript( + parserId: configuration.parserId, + pattern: 'build.log' + )] + ]) + ) + } +} From 338314b08a7ee28db88e7f3f656de1f2dfd446eb Mon Sep 17 00:00:00 2001 From: Florian Wilhelm Date: Thu, 18 Jul 2019 13:23:19 +0200 Subject: [PATCH 009/141] Use test project from new repo (#797) --- consumer-test/testCases/s4sdk/consumer-test-neo.yml | 2 +- consumer-test/testCases/s4sdk/consumer-test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/consumer-test/testCases/s4sdk/consumer-test-neo.yml b/consumer-test/testCases/s4sdk/consumer-test-neo.yml index 6dae047cd..ca0801617 100644 --- a/consumer-test/testCases/s4sdk/consumer-test-neo.yml +++ b/consumer-test/testCases/s4sdk/consumer-test-neo.yml @@ -1,2 +1,2 @@ # Test case configuration -referenceAppRepoUrl: "https://github.com/sap/cloud-s4-sdk-book" +referenceAppRepoUrl: "https://github.com/piper-validation/cloud-s4-sdk-book" diff --git a/consumer-test/testCases/s4sdk/consumer-test.yml b/consumer-test/testCases/s4sdk/consumer-test.yml index 6dae047cd..ca0801617 100644 --- a/consumer-test/testCases/s4sdk/consumer-test.yml +++ b/consumer-test/testCases/s4sdk/consumer-test.yml @@ -1,2 +1,2 @@ # Test case configuration -referenceAppRepoUrl: "https://github.com/sap/cloud-s4-sdk-book" +referenceAppRepoUrl: "https://github.com/piper-validation/cloud-s4-sdk-book" From cb245b1ce25f4e7ed90b7d80ed765ec2a934c7ad Mon Sep 17 00:00:00 2001 From: tobiaslendle <52666755+tobiaslendle@users.noreply.github.com> Date: Thu, 18 Jul 2019 15:06:11 +0200 Subject: [PATCH 010/141] TMS integration (#782) --- documentation/docs/steps/tmsUpload.md | 9 + resources/default_pipeline_environment.yml | 4 + src/com/sap/piper/JenkinsUtils.groovy | 4 + .../TransportManagementService.groovy | 137 +++++++++++++ test/groovy/TmsUploadTest.groovy | 182 ++++++++++++++++++ .../com/sap/piper/JenkinsUtilsTest.groovy | 25 ++- .../TransportManagementServiceTest.groovy | 181 +++++++++++++++++ .../responseAuth.txt | 3 + .../responseFileUpload.txt | 3 + .../responseNodeUpload.txt | 3 + vars/piperPipelineStageRelease.groovy | 8 + vars/tmsUpload.groovy | 131 +++++++++++++ 12 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 documentation/docs/steps/tmsUpload.md create mode 100644 src/com/sap/piper/integration/TransportManagementService.groovy create mode 100644 test/groovy/TmsUploadTest.groovy create mode 100644 test/groovy/com/sap/piper/integration/TransportManagementServiceTest.groovy create mode 100644 test/resources/TransportManagementService/responseAuth.txt create mode 100644 test/resources/TransportManagementService/responseFileUpload.txt create mode 100644 test/resources/TransportManagementService/responseNodeUpload.txt create mode 100644 vars/tmsUpload.groovy diff --git a/documentation/docs/steps/tmsUpload.md b/documentation/docs/steps/tmsUpload.md new file mode 100644 index 000000000..3d8f3171a --- /dev/null +++ b/documentation/docs/steps/tmsUpload.md @@ -0,0 +1,9 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 4c32565f2..6f3a352f8 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -535,6 +535,10 @@ steps: active: false checkChangeInDevelopment: failIfStatusIsNotInDevelopment: true + tmsUpload: + namedUser: 'Piper-Pipeline' + stashContent: + - 'buildResult' transportRequestCreate: developmentSystemId: null verbose: false diff --git a/src/com/sap/piper/JenkinsUtils.groovy b/src/com/sap/piper/JenkinsUtils.groovy index c54a87818..58296f7a9 100644 --- a/src/com/sap/piper/JenkinsUtils.groovy +++ b/src/com/sap/piper/JenkinsUtils.groovy @@ -104,3 +104,7 @@ String getIssueCommentTriggerAction() { return null } } + +def getJobStartedByUserId() { + return getRawBuild().getCause(hudson.model.Cause.UserIdCause.class)?.getUserId() +} diff --git a/src/com/sap/piper/integration/TransportManagementService.groovy b/src/com/sap/piper/integration/TransportManagementService.groovy new file mode 100644 index 000000000..7ac5caa4b --- /dev/null +++ b/src/com/sap/piper/integration/TransportManagementService.groovy @@ -0,0 +1,137 @@ +package com.sap.piper.integration + +import com.sap.piper.JsonUtils + +class TransportManagementService implements Serializable { + + final Script script + final Map config + + def jsonUtils = new JsonUtils() + + TransportManagementService(Script script, Map config) { + this.script = script + this.config = config + } + + def authentication(String uaaUrl, String oauthClientId, String oauthClientSecret) { + echo("OAuth Token retrieval started.") + + if (config.verbose) { + echo("UAA-URL: '${uaaUrl}', ClientId: '${oauthClientId}''") + } + + def encodedUsernameColonPassword = "${oauthClientId}:${oauthClientSecret}".bytes.encodeBase64().toString() + def urlEncodedFormData = "grant_type=password&" + + "username=${urlEncodeAndReplaceSpace(oauthClientId)}&" + + "password=${urlEncodeAndReplaceSpace(oauthClientSecret)}" + + def parameters = [ + url : "${uaaUrl}/oauth/token/?grant_type=client_credentials&response_type=token", + httpMode : "POST", + requestBody : urlEncodedFormData, + customHeaders: [ + [ + maskValue: false, + name : 'Content-Type', + value : 'application/x-www-form-urlencoded' + ], + [ + maskValue: true, + name : 'authorization', + value : "Basic ${encodedUsernameColonPassword}" + ] + ] + ] + + def response = sendApiRequest(parameters) + echo("OAuth Token retrieved successfully.") + + return jsonUtils.jsonStringToGroovyObject(response).access_token + + } + + + def uploadFile(String url, String token, String file, String namedUser) { + + echo("File upload started.") + + if (config.verbose) { + echo("URL: '${url}', File: '${file}'") + } + + script.sh """#!/bin/sh -e + curl -H 'Authorization: Bearer ${token}' -F 'file=@${file}' -F 'namedUser=${namedUser}' -o responseFileUpload.txt --fail '${url}/v2/files/upload' + """ + + def responseContent = script.readFile("responseFileUpload.txt") + + if (config.verbose) { + echo("${responseContent}") + } + + echo("File upload successful.") + + return jsonUtils.jsonStringToGroovyObject(responseContent) + + } + + + def uploadFileToNode(String url, String token, String nodeName, int fileId, String description, String namedUser) { + + echo("Node upload started.") + + if (config.verbose) { + echo("URL: '${url}', NodeName: '${nodeName}', FileId: '${fileId}''") + } + + def bodyMap = [nodeName: nodeName, contentType: 'MTA', description: description, storageType: 'FILE', namedUser: namedUser, entries: [[uri: fileId]]] + + def parameters = [ + url : "${url}/v2/nodes/upload", + httpMode : "POST", + contentType : 'APPLICATION_JSON', + requestBody : jsonUtils.groovyObjectToPrettyJsonString(bodyMap), + customHeaders: [ + [ + maskValue: true, + name : 'authorization', + value : "Bearer ${token}" + ] + ] + ] + + def response = sendApiRequest(parameters) + echo("Node upload successful.") + + return jsonUtils.jsonStringToGroovyObject(response) + + } + + private sendApiRequest(parameters) { + def defaultParameters = [ + acceptType : 'APPLICATION_JSON', + quiet : !config.verbose, + consoleLogResponseBody: !config.verbose, + ignoreSslErrors : true, + validResponseCodes : "100:399" + ] + + def response = script.httpRequest(defaultParameters + parameters) + + if (config.verbose) { + echo("Received response '${response.content}' with status ${response.status}.") + } + + return response.content + } + + private echo(message) { + script.echo "[${getClass().getSimpleName()}] ${message}" + } + + private static String urlEncodeAndReplaceSpace(String data) { + return URLEncoder.encode(data, "UTF-8").replace('%20', '+') + } + +} diff --git a/test/groovy/TmsUploadTest.groovy b/test/groovy/TmsUploadTest.groovy new file mode 100644 index 000000000..e16ecb895 --- /dev/null +++ b/test/groovy/TmsUploadTest.groovy @@ -0,0 +1,182 @@ +import com.sap.piper.JenkinsUtils +import com.sap.piper.integration.TransportManagementService +import org.junit.After +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.containsString +import static org.hamcrest.Matchers.is +import static org.hamcrest.Matchers.not +import static org.junit.Assert.assertThat + +public class TmsUploadTest extends BasePiperTest { + + private ExpectedException thrown = new ExpectedException() + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private JenkinsEnvironmentRule envRule = new JenkinsEnvironmentRule(this) + + def tmsStub + def jenkinsUtilsStub + def calledTmsMethodsWithArgs = [] + def uri = "https://dummy-url.com" + def uaaUrl = "https://oauth.com" + def oauthClientId = "myClientId" + def oauthClientSecret = "myClientSecret" + def serviceKeyContent = """{ + "uri": "${uri}", + "uaa": { + "clientid": "${oauthClientId}", + "clientsecret": "${oauthClientSecret}", + "url": "${uaaUrl}" + } + } + """ + + class JenkinsUtilsMock extends JenkinsUtils { + def userId + + JenkinsUtilsMock(userId) { + this.userId = userId + } + + def getJobStartedByUserId(){ + return this.userId + } + } + + @Rule + public RuleChain ruleChain = Rules.getCommonRules(this) + .around(thrown) + .around(new JenkinsReadYamlRule(this)) + .around(stepRule) + .around(loggingRule) + .around(envRule) + .around(new JenkinsCredentialsRule(this) + .withCredentials('TMS_ServiceKey', serviceKeyContent)) + + @Before + public void setup() { + tmsStub = mockTransportManagementService() + helper.registerAllowedMethod("unstash", [String.class], { s -> return [s] }) + } + + @After + void tearDown() { + calledTmsMethodsWithArgs.clear() + } + + @Test + public void minimalConfig__isSuccessful() { + jenkinsUtilsStub = new JenkinsUtilsMock("Test User") + binding.workspace = "." + envRule.env.gitCommitId = "testCommitId" + + stepRule.step.tmsUpload( + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: jenkinsUtilsStub, + transportManagementService: tmsStub, + mtaPath: 'dummy.mtar', + nodeName: 'myNode', + credentialsId: 'TMS_ServiceKey' + ) + + assertThat(calledTmsMethodsWithArgs[0], is("authentication('${uaaUrl}', '${oauthClientId}', '${oauthClientSecret}')")) + assertThat(calledTmsMethodsWithArgs[1], is("uploadFile('${uri}', 'myToken', './dummy.mtar', 'Test User')")) + assertThat(calledTmsMethodsWithArgs[2], is("uploadFileToNode('${uri}', 'myToken', 'myNode', '1234', 'Git CommitId: testCommitId')")) + assertThat(loggingRule.log, containsString("[TransportManagementService] File './dummy.mtar' successfully uploaded to Node 'myNode' (Id: '1000').")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Corresponding Transport Request: 'Git CommitId: testCommitId' (Id: '2000')")) + assertThat(loggingRule.log, not(containsString("[TransportManagementService] CredentialsId: 'TMS_ServiceKey'"))) + + } + + @Test + public void verboseMode__yieldsMoreEchos() { + jenkinsUtilsStub = new JenkinsUtilsMock("Test User") + binding.workspace = "." + envRule.env.gitCommitId = "testCommitId" + + stepRule.step.tmsUpload( + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: jenkinsUtilsStub, + transportManagementService: tmsStub, + mtaPath: 'dummy.mtar', + nodeName: 'myNode', + credentialsId: 'TMS_ServiceKey', + verbose: true + ) + + assertThat(loggingRule.log, containsString("[TransportManagementService] CredentialsId: 'TMS_ServiceKey'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Node name: 'myNode'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] MTA path: 'dummy.mtar'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Named user: 'Test User'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] UAA URL: '${uaaUrl}'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] TMS URL: '${uri}'")) + assertThat(loggingRule.log, containsString("[TransportManagementService] ClientId: '${oauthClientId}'")) + } + + @Test + public void noUserAvailableInCurrentBuild__usesDefaultUser() { + jenkinsUtilsStub = new JenkinsUtilsMock(null) + binding.workspace = "." + envRule.env.gitCommitId = "testCommitId" + + stepRule.step.tmsUpload( + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: jenkinsUtilsStub, + transportManagementService: tmsStub, + mtaPath: 'dummy.mtar', + nodeName: 'myNode', + credentialsId: 'TMS_ServiceKey' + ) + + assertThat(calledTmsMethodsWithArgs[1], is("uploadFile('${uri}', 'myToken', './dummy.mtar', 'Piper-Pipeline')")) + } + + @Test + public void addCustomDescription__descriptionChanged() { + jenkinsUtilsStub = new JenkinsUtilsMock("Test User") + binding.workspace = "." + envRule.env.gitCommitId = "testCommitId" + + stepRule.step.tmsUpload( + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: jenkinsUtilsStub, + transportManagementService: tmsStub, + mtaPath: 'dummy.mtar', + nodeName: 'myNode', + credentialsId: 'TMS_ServiceKey', + customDescription: 'My custom description for testing.' + ) + + assertThat(calledTmsMethodsWithArgs[2], is("uploadFileToNode('${uri}', 'myToken', 'myNode', '1234', 'My custom description for testing.')")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Corresponding Transport Request: 'My custom description for testing.' (Id: '2000')")) + } + + def mockTransportManagementService() { + return new TransportManagementService(nullScript, [:]) { + def authentication(String uaaUrl, String oauthClientId, String oauthClientSecret) { + calledTmsMethodsWithArgs << "authentication('${uaaUrl}', '${oauthClientId}', '${oauthClientSecret}')" + return "myToken" + } + + def uploadFile(String url, String token, String file, String namedUser) { + calledTmsMethodsWithArgs << "uploadFile('${url}', '${token}', '${file}', '${namedUser}')" + return [fileId: 1234, fileName: file] + } + + def uploadFileToNode(String url, String token, String nodeName, int fileId, String description, String namedUser) { + calledTmsMethodsWithArgs << "uploadFileToNode('${url}', '${token}', '${nodeName}', '${fileId}', '${description}')" + return [transportRequestDescription: description, transportRequestId: 2000, queueEntries: [nodeName: 'myNode', nodeId: 1000]] + } + } + } +} diff --git a/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy b/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy index 945d690df..b915bc82e 100644 --- a/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy +++ b/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy @@ -4,7 +4,6 @@ import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException 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 @@ -33,6 +32,8 @@ class JenkinsUtilsTest extends BasePiperTest { Map triggerCause + String userId + @Before void init() throws Exception { @@ -60,7 +61,15 @@ class JenkinsUtilsTest extends BasePiperTest { return parentMock } def getCause(type) { - return triggerCause + if (type == hudson.model.Cause.UserIdCause.class){ + def userIdCause = new hudson.model.Cause.UserIdCause() + userIdCause.metaClass.getUserId = { + return userId + } + return userIdCause + } else { + return triggerCause + } } } @@ -109,4 +118,16 @@ class JenkinsUtilsTest extends BasePiperTest { ] assertThat(jenkinsUtils.getIssueCommentTriggerAction(), isEmptyOrNullString()) } + + @Test + void testGetUserId() { + userId = 'Test User' + assertThat(jenkinsUtils.getJobStartedByUserId(), is('Test User')) + } + + @Test + void testGetUserIdNoUser() { + userId = null + assertThat(jenkinsUtils.getJobStartedByUserId(), isEmptyOrNullString()) + } } diff --git a/test/groovy/com/sap/piper/integration/TransportManagementServiceTest.groovy b/test/groovy/com/sap/piper/integration/TransportManagementServiceTest.groovy new file mode 100644 index 000000000..fbd68e14d --- /dev/null +++ b/test/groovy/com/sap/piper/integration/TransportManagementServiceTest.groovy @@ -0,0 +1,181 @@ +package com.sap.piper.integration + +import hudson.AbortException +import org.junit.Rule +import org.junit.Test +import org.junit.Ignore +import org.junit.rules.ExpectedException +import org.junit.rules.RuleChain +import util.* + +import static org.hamcrest.Matchers.* +import static org.junit.Assert.assertThat + +class TransportManagementServiceTest extends BasePiperTest { + private ExpectedException thrown = ExpectedException.none() + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(new JenkinsErrorRule(this)) + .around(new JenkinsReadJsonRule(this)) + .around(shellRule) + .around(loggingRule) + .around(new JenkinsReadFileRule(this, 'test/resources/TransportManagementService')) + .around(thrown) + + @Test + void retrieveOAuthToken__successfully() { + Map requestParams + helper.registerAllowedMethod('httpRequest', [Map.class], { m -> + requestParams = m + return [content: '{ "access_token": "myOAuthToken" }'] + }) + + def uaaUrl = 'http://dummy.com/oauth' + def clientId = 'myId' + def clientSecret = 'mySecret' + + def tms = new TransportManagementService(nullScript, [:]) + def token = tms.authentication(uaaUrl, clientId, clientSecret) + + assertThat(loggingRule.log, containsString("[TransportManagementService] OAuth Token retrieval started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] OAuth Token retrieved successfully.")) + assertThat(token, is('myOAuthToken')) + assertThat(requestParams, hasEntry('url', "${uaaUrl}/oauth/token/?grant_type=client_credentials&response_type=token")) + assertThat(requestParams, hasEntry('requestBody', "grant_type=password&username=${clientId}&password=${clientSecret}".toString())) + assertThat(requestParams.customHeaders[1].value, is("Basic ${"${clientId}:${clientSecret}".bytes.encodeBase64()}")) + } + + @Test + void retrieveOAuthToken__inVerboseMode__yieldsMoreEchos() { + Map requestParams + helper.registerAllowedMethod('httpRequest', [Map.class], { m -> + requestParams = m + return [content: '{ "access_token": "myOAuthToken" }', status: 200] + }) + + def uaaUrl = 'http://dummy.com/oauth' + def clientId = 'myId' + def clientSecret = 'mySecret' + + def tms = new TransportManagementService(nullScript, [verbose: true]) + tms.authentication(uaaUrl, clientId, clientSecret) + + assertThat(loggingRule.log, containsString("[TransportManagementService] OAuth Token retrieval started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] UAA-URL: '${uaaUrl}', ClientId: '${clientId}'")) + assertThat(loggingRule.log, containsString("Received response '{ \"access_token\": \"myOAuthToken\" }' with status 200.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] OAuth Token retrieved successfully.")) + } + + @Test + void uploadFile__successfully() { + + def url = 'http://dummy.com/oauth' + def token = 'myToken' + def file = 'myFile.mtar' + def namedUser = 'myUser' + + def tms = new TransportManagementService(nullScript, [:]) + def responseDetails = tms.uploadFile(url, token, file, namedUser) + + def oAuthShellCall = shellRule.shell[0] + + assertThat(loggingRule.log, containsString("[TransportManagementService] File upload started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] File upload successful.")) + assertThat(oAuthShellCall, startsWith("#!/bin/sh -e ")) + assertThat(oAuthShellCall, endsWith("curl -H 'Authorization: Bearer ${token}' -F 'file=@${file}' -F 'namedUser=${namedUser}' -o responseFileUpload.txt --fail '${url}/v2/files/upload'")) + assertThat(responseDetails, hasEntry("fileId", 1234)) + } + + @Ignore + void uploadFile__withHttpErrorResponse__throwsError() { + + def url = 'http://dummy.com/oauth' + def token = 'myWrongToken' + def file = 'myFile.mtar' + def namedUser = 'myUser' + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, ".* curl .*", {throw new AbortException()}) + + thrown.expect(AbortException.class) + + def tms = new TransportManagementService(nullScript, [:]) + tms.uploadFile(url, token, file, namedUser) + + } + + @Test + void uploadFile__inVerboseMode__yieldsMoreEchos() { + + def url = 'http://dummy.com/oauth' + def token = 'myToken' + def file = 'myFile.mtar' + def namedUser = 'myUser' + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, ".* curl .*", '200') + + def tms = new TransportManagementService(nullScript, [verbose: true]) + tms.uploadFile(url, token, file, namedUser) + + assertThat(loggingRule.log, containsString("[TransportManagementService] File upload started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] URL: '${url}', File: '${file}'")) + assertThat(loggingRule.log, containsString("\"fileId\": 1234")) + assertThat(loggingRule.log, containsString("[TransportManagementService] File upload successful.")) + } + + @Test + void uploadFileToNode__successfully() { + Map requestParams + helper.registerAllowedMethod('httpRequest', [Map.class], { m -> + requestParams = m + return [content: '{ "upload": "success" }'] + }) + + def url = 'http://dummy.com/oauth' + def token = 'myToken' + def nodeName = 'myNode' + def fileId = 1234 + def description = "My description." + def namedUser = 'myUser' + + def tms = new TransportManagementService(nullScript, [:]) + def responseDetails = tms.uploadFileToNode(url, token, nodeName, fileId, description, namedUser) + + def bodyRegEx = /^\{\s+"nodeName":\s+"myNode",\s+"contentType":\s+"MTA",\s+"description":\s+"My\s+description.",\s+"storageType":\s+"FILE",\s+"namedUser":\s+"myUser",\s+"entries":\s+\[\s+\{\s+"uri":\s+1234\s+}\s+]\s+}$/ + + assertThat(loggingRule.log, containsString("[TransportManagementService] Node upload started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Node upload successful.")) + assertThat(requestParams, hasEntry('url', "${url}/v2/nodes/upload")) + assert requestParams.requestBody ==~ bodyRegEx + assertThat(requestParams.customHeaders[0].value, is("Bearer ${token}")) + assertThat(responseDetails, hasEntry("upload", "success")) + } + + @Test + void uploadFileToNode__inVerboseMode__yieldsMoreEchos() { + Map requestParams + helper.registerAllowedMethod('httpRequest', [Map.class], { m -> + requestParams = m + return [content: '{ "upload": "success" }'] + }) + + def url = 'http://dummy.com/oauth' + def token = 'myToken' + def nodeName = 'myNode' + def fileId = 1234 + def description = "My description." + def namedUser = 'myUser' + + def tms = new TransportManagementService(nullScript, [verbose: true]) + tms.uploadFileToNode(url, token, nodeName, fileId, description, namedUser) + + assertThat(loggingRule.log, containsString("[TransportManagementService] Node upload started.")) + assertThat(loggingRule.log, containsString("[TransportManagementService] URL: '${url}', NodeName: '${nodeName}', FileId: '${fileId}'")) + assertThat(loggingRule.log, containsString("\"upload\": \"success\"")) + assertThat(loggingRule.log, containsString("[TransportManagementService] Node upload successful.")) + } + +} diff --git a/test/resources/TransportManagementService/responseAuth.txt b/test/resources/TransportManagementService/responseAuth.txt new file mode 100644 index 000000000..287aa348c --- /dev/null +++ b/test/resources/TransportManagementService/responseAuth.txt @@ -0,0 +1,3 @@ +{ + "access_token": "myOAuthToken" +} diff --git a/test/resources/TransportManagementService/responseFileUpload.txt b/test/resources/TransportManagementService/responseFileUpload.txt new file mode 100644 index 000000000..c9ed9848e --- /dev/null +++ b/test/resources/TransportManagementService/responseFileUpload.txt @@ -0,0 +1,3 @@ +{ + "fileId": 1234 +} diff --git a/test/resources/TransportManagementService/responseNodeUpload.txt b/test/resources/TransportManagementService/responseNodeUpload.txt new file mode 100644 index 000000000..7cb57b065 --- /dev/null +++ b/test/resources/TransportManagementService/responseNodeUpload.txt @@ -0,0 +1,3 @@ +{ + "upload": "success" +} diff --git a/vars/piperPipelineStageRelease.groovy b/vars/piperPipelineStageRelease.groovy index 88396daa3..f3a12d22a 100644 --- a/vars/piperPipelineStageRelease.groovy +++ b/vars/piperPipelineStageRelease.groovy @@ -15,6 +15,8 @@ import static com.sap.piper.Prerequisites.checkScript 'healthExecuteCheck', /** For Neo use-cases: Performs deployment to Neo landscape. */ 'neoDeploy', + /** For TMS use-cases: Performs upload to Transport Management Service node*/ + 'tmsUpload', /** Publishes release information to GitHub. */ 'githubPublishRelease', ] @@ -60,6 +62,12 @@ void call(Map parameters = [:]) { } } + if (config.tmsUpload) { + durationMeasure(script: script, measurementName: 'upload_release_tms_duration') { + tmsUpload script: script + } + } + if (config.healthExecuteCheck) { healthExecuteCheck script: script } diff --git a/vars/tmsUpload.groovy b/vars/tmsUpload.groovy new file mode 100644 index 000000000..6dc77ae5a --- /dev/null +++ b/vars/tmsUpload.groovy @@ -0,0 +1,131 @@ +import com.sap.piper.ConfigurationHelper +import com.sap.piper.GenerateDocumentation +import com.sap.piper.JenkinsUtils +import com.sap.piper.JsonUtils +import com.sap.piper.Utils +import com.sap.piper.integration.TransportManagementService +import groovy.transform.Field + +import static com.sap.piper.Prerequisites.checkScript + +@Field String STEP_NAME = getClass().getName() + +@Field Set GENERAL_CONFIG_KEYS = [ + /** + * Print more detailed information into the log. + * @possibleValues `true`, `false` + */ + 'verbose' +] +@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ + /** + * If specific stashes should be considered, their names need to be passed via the parameter `stashContent`. + */ + 'stashContent', + /** + * Defines the path to *.mtar for the upload to the Transport Management Service. + */ + 'mtaPath', + /** + * Defines the name of the node to which the *.mtar file should be uploaded. + */ + 'nodeName', + /** + * Credentials to be used for the file and node uploads to the Transport Management Service. + */ + 'credentialsId', + /** + * Can be used as the description of a transport request. Will overwrite the default. (Default: Corresponding Git Commit-ID) + */ + 'customDescription' +]) +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +/** + * This step allows you to upload an MTA file (multi-target application archive) into a TMS (SAP Cloud Platform Transport Management Service) landscape for further TMS-controlled distribution through a TMS-configured landscape. + * TMS lets you manage transports between SAP Cloud Platform accounts in Neo and Cloud Foundry, such as from DEV to TEST and PROD accounts. + * For more information, see [official documentation of Transport Management Service](https://help.sap.com/viewer/p/TRANSPORT_MANAGEMENT_SERVICE) + * + * !!! note "Prerequisites" + * * You have subscribed to and set up TMS, as described in [Setup and Configuration of SAP Cloud Platform Transport Management](https://help.sap.com/viewer/7f7160ec0d8546c6b3eab72fb5ad6fd8/Cloud/en-US/66fd7283c62f48adb23c56fb48c84a60.html), which includes the configuration of a node to be used for uploading an MTA file. + * * A corresponding service key has been created, as described in [Set Up the Environment to Transport Content Archives directly in an Application](https://help.sap.com/viewer/7f7160ec0d8546c6b3eab72fb5ad6fd8/Cloud/en-US/8d9490792ed14f1bbf8a6ac08a6bca64.html). This service key (JSON) must be stored as a secret text within the Jenkins secure store. + * + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters) { + + def script = checkScript(this, parameters) ?: this + def utils = parameters.juStabUtils ?: new Utils() + def jenkinsUtils = parameters.jenkinsUtilsStub ?: new JenkinsUtils() + + // load default & individual configuration + Map config = ConfigurationHelper.newInstance(this) + .loadStepDefaults() + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName ?: env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixin(parameters, PARAMETER_KEYS) + //mandatory parameters + .withMandatoryProperty('mtaPath') + .withMandatoryProperty('nodeName') + .withMandatoryProperty('credentialsId') + .use() + + // telemetry reporting + new Utils().pushToSWA([ + step : STEP_NAME, + stepParamKey1: 'scriptMissing', + stepParam1 : parameters?.script == null + ], config) + + def jsonUtilsObject = new JsonUtils() + + // make sure that all relevant descriptors, are available in workspace + utils.unstashAll(config.stashContent) + // make sure that for further execution whole workspace, e.g. also downloaded artifacts are considered + config.stashContent = [] + + def customDescription = config.customDescription ? "${config.customDescription}" : "Git CommitId: ${script.commonPipelineEnvironment.getGitCommitId()}" + def description = customDescription + + def namedUser = jenkinsUtils.getJobStartedByUserId() ?: config.namedUser + + def nodeName = config.nodeName + def mtaPath = config.mtaPath + + if (config.verbose) { + echo "[TransportManagementService] CredentialsId: '${config.credentialsId}'" + echo "[TransportManagementService] Node name: '${nodeName}'" + echo "[TransportManagementService] MTA path: '${mtaPath}'" + echo "[TransportManagementService] Named user: '${namedUser}'" + } + + def tms = parameters.transportManagementService ?: new TransportManagementService(script, config) + + withCredentials([string(credentialsId: config.credentialsId, variable: 'tmsServiceKeyJSON')]) { + + def tmsServiceKey = jsonUtilsObject.jsonStringToGroovyObject(tmsServiceKeyJSON) + + def clientId = tmsServiceKey.uaa.clientid + def clientSecret = tmsServiceKey.uaa.clientsecret + def uaaUrl = tmsServiceKey.uaa.url + def uri = tmsServiceKey.uri + + if (config.verbose) { + echo "[TransportManagementService] UAA URL: '${uaaUrl}'" + echo "[TransportManagementService] TMS URL: '${uri}'" + echo "[TransportManagementService] ClientId: '${clientId}'" + } + + def token = tms.authentication(uaaUrl, clientId, clientSecret) + def fileUploadResponse = tms.uploadFile(uri, token, "${workspace}/${mtaPath}", namedUser) + def uploadFileToNodeResponse = tms.uploadFileToNode(uri, token, nodeName, fileUploadResponse.fileId, description, namedUser) + + echo "[TransportManagementService] File '${fileUploadResponse.fileName}' successfully uploaded to Node '${uploadFileToNodeResponse.queueEntries.nodeName}' (Id: '${uploadFileToNodeResponse.queueEntries.nodeId}')." + echo "[TransportManagementService] Corresponding Transport Request: '${uploadFileToNodeResponse.transportRequestDescription}' (Id: '${uploadFileToNodeResponse.transportRequestId}')" + + } + + } +} From 3d94ce4770747a75c40c6e176ec79bcc23ad5d95 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 22 Jul 2019 12:08:11 +0200 Subject: [PATCH 011/141] Add tmpUpload to mkdocs index page (#801) --- documentation/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 552328a21..8177dca37 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -45,6 +45,7 @@ nav: - snykExecute: steps/snykExecute.md - sonarExecuteScan: steps/sonarExecuteScan.md - testsPublishResults: steps/testsPublishResults.md + - tmsUpload: steps/tmsUpload.md - transportRequestCreate: steps/transportRequestCreate.md - transportRequestRelease: steps/transportRequestRelease.md - transportRequestUploadFile: steps/transportRequestUploadFile.md From 79bc304c092039b33ce72cb5c3208f33b58c2bdc Mon Sep 17 00:00:00 2001 From: Oliver Feldmann Date: Tue, 23 Jul 2019 14:54:38 +0200 Subject: [PATCH 012/141] add ui5 consumer test (#802) --- consumer-test/TestRunnerThread.groovy | 20 +++++++++++++------ consumer-test/consumerTestController.groovy | 4 ---- consumer-test/jenkins.yml | 6 ++++++ .../testCases/s4sdk/consumer-test-neo.yml | 7 ++++++- .../testCases/s4sdk/consumer-test.yml | 7 ++++++- consumer-test/testCases/scs/ui5-neo.yml | 7 +++++++ 6 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 consumer-test/testCases/scs/ui5-neo.yml diff --git a/consumer-test/TestRunnerThread.groovy b/consumer-test/TestRunnerThread.groovy index cf3982349..61b289c98 100644 --- a/consumer-test/TestRunnerThread.groovy +++ b/consumer-test/TestRunnerThread.groovy @@ -22,6 +22,11 @@ class TestRunnerThread extends Thread { def testCaseConfig TestRunnerThread(File testCaseFile) { + this.testCaseConfig = new Yaml().load(testCaseFile.text) + if (!System.getenv(testCaseConfig.deployCredentialEnv.username) || + !System.getenv(testCaseConfig.deployCredentialEnv.password)) { + throw new RuntimeException("Environment variables '${testCaseConfig.deployCredentialEnv.username}' and '${testCaseConfig.deployCredentialEnv.password}' need to be set.") + } // Regex pattern expects a folder structure such as '/rootDir/areaDir/testCase.extension' def testCaseMatches = (testCaseFile.toString() =~ /^[\w\-]+\\/([\w\-]+)\\/([\w\-]+)\..*\u0024/) @@ -34,7 +39,6 @@ class TestRunnerThread extends Thread { this.uniqueName = "${area}|${testCase}" this.testCaseRootDir = new File("${workspacesRootDir}/${area}/${testCase}") this.testCaseWorkspace = "${testCaseRootDir}/workspace" - this.testCaseConfig = new Yaml().load(testCaseFile.text) } void run() { @@ -43,8 +47,10 @@ class TestRunnerThread extends Thread { if (testCaseRootDir.exists() || !testCaseRootDir.mkdirs()) { throw new RuntimeException("Creation of dir '${testCaseRootDir}' failed.") } - executeShell("git clone -b ${testCase} ${testCaseConfig.referenceAppRepoUrl} " + - "${testCaseWorkspace}") + + executeShell("git clone -b ${testCaseConfig.referenceAppRepo.branch} " + + "${testCaseConfig.referenceAppRepo.url} ${testCaseWorkspace}") + addJenkinsYmlToWorkspace() setLibraryVersionInJenkinsfile() @@ -53,10 +59,12 @@ class TestRunnerThread extends Thread { '--author="piper-testing-bot "', '--message="Set piper lib version for test"']) - executeShell("docker run -v /var/run/docker.sock:/var/run/docker.sock " + + executeShell("docker run --rm -v /var/run/docker.sock:/var/run/docker.sock " + "-v ${System.getenv('PWD')}/${testCaseWorkspace}:/workspace -v /tmp " + - "-e CASC_JENKINS_CONFIG=/workspace/jenkins.yml -e CX_INFRA_IT_CF_USERNAME " + - "-e CX_INFRA_IT_CF_PASSWORD -e BRANCH_NAME=${testCase} ppiper/jenkinsfile-runner") + "-e CASC_JENKINS_CONFIG=/workspace/jenkins.yml " + + "-e ${testCaseConfig.deployCredentialEnv.username} " + + "-e ${testCaseConfig.deployCredentialEnv.password} " + + "-e BRANCH_NAME=${testCaseConfig.referenceAppRepo.branch} ppiper/jenkinsfile-runner") println "*****[INFO] Test case '${uniqueName}' finished successfully.*****" printOutput() diff --git a/consumer-test/consumerTestController.groovy b/consumer-test/consumerTestController.groovy index 44658f0b7..506eb9929 100644 --- a/consumer-test/consumerTestController.groovy +++ b/consumer-test/consumerTestController.groovy @@ -68,10 +68,6 @@ if (!RUNNING_LOCALLY) { } } -if (!System.getenv('CX_INFRA_IT_CF_USERNAME') || !System.getenv('CX_INFRA_IT_CF_PASSWORD')) { - exitPrematurely('Environment variables CX_INFRA_IT_CF_USERNAME and CX_INFRA_IT_CF_PASSWORD need to be set.') -} - if (options.s) { def file = new File(options.s) if (!file.exists()) { diff --git a/consumer-test/jenkins.yml b/consumer-test/jenkins.yml index 84eff57ee..546029dcf 100644 --- a/consumer-test/jenkins.yml +++ b/consumer-test/jenkins.yml @@ -27,3 +27,9 @@ credentials: username: ${CX_INFRA_IT_CF_USERNAME} password: ${CX_INFRA_IT_CF_PASSWORD} description: "SAP CP Trail account for test deployment" + - usernamePassword: + scope: GLOBAL + id: "neo_deploy" + username: ${NEO_DEPLOY_USERNAME} + password: ${NEO_DEPLOY_PASSWORD} + description: "SAP CP NEO Trail account for test deployment" diff --git a/consumer-test/testCases/s4sdk/consumer-test-neo.yml b/consumer-test/testCases/s4sdk/consumer-test-neo.yml index ca0801617..81ffb9e87 100644 --- a/consumer-test/testCases/s4sdk/consumer-test-neo.yml +++ b/consumer-test/testCases/s4sdk/consumer-test-neo.yml @@ -1,2 +1,7 @@ # Test case configuration -referenceAppRepoUrl: "https://github.com/piper-validation/cloud-s4-sdk-book" +referenceAppRepo: + url: "https://github.com/piper-validation/cloud-s4-sdk-book" + branch: "consumer-test-neo" +deployCredentialEnv: + username: "CX_INFRA_IT_CF_USERNAME" + password: "CX_INFRA_IT_CF_PASSWORD" diff --git a/consumer-test/testCases/s4sdk/consumer-test.yml b/consumer-test/testCases/s4sdk/consumer-test.yml index ca0801617..3c2112253 100644 --- a/consumer-test/testCases/s4sdk/consumer-test.yml +++ b/consumer-test/testCases/s4sdk/consumer-test.yml @@ -1,2 +1,7 @@ # Test case configuration -referenceAppRepoUrl: "https://github.com/piper-validation/cloud-s4-sdk-book" +referenceAppRepo: + url: "https://github.com/piper-validation/cloud-s4-sdk-book" + branch: "consumer-test" +deployCredentialEnv: + username: "CX_INFRA_IT_CF_USERNAME" + password: "CX_INFRA_IT_CF_PASSWORD" diff --git a/consumer-test/testCases/scs/ui5-neo.yml b/consumer-test/testCases/scs/ui5-neo.yml new file mode 100644 index 000000000..412ad3681 --- /dev/null +++ b/consumer-test/testCases/scs/ui5-neo.yml @@ -0,0 +1,7 @@ +# Test case configuration +referenceAppRepo: + url: "https://github.com/piper-validation/openui5-sample-app.git" + branch: "piper-test-ui5-neo" +deployCredentialEnv: + username: "NEO_DEPLOY_USERNAME" + password: "NEO_DEPLOY_PASSWORD" From 01ce79724522e8e3bb154df3e5a46be4c2c7f0a7 Mon Sep 17 00:00:00 2001 From: Roland Stengel Date: Thu, 25 Jul 2019 11:57:21 +0200 Subject: [PATCH 013/141] harmonize docker configuration properties support the configuration of the docker arguments dockerEnvVars dockerOptions dockerWorkspace for all steps. --- test/groovy/BatsExecuteTestsTest.groovy | 26 ++++++++++ test/groovy/GaugeExecuteTestsTest.groovy | 28 +++++++++++ test/groovy/KarmaExecuteTestsTest.groovy | 26 ++++++++++ test/groovy/MtaBuildTest.groovy | 23 +++++++++ test/groovy/NewmanExecuteTest.groovy | 26 ++++++++++ test/groovy/NpmExecuteTest.groovy | 26 ++++++++++ test/groovy/SeleniumExecuteTestsTest.groovy | 27 +++++++++++ test/groovy/SnykExecuteTest.groovy | 31 ++++++++++++ test/groovy/WhitesourceExecuteScanTest.groovy | 48 +++++++++++++++++++ vars/batsExecuteTests.groovy | 13 ++++- vars/gaugeExecuteTests.groovy | 7 ++- vars/karmaExecuteTests.groovy | 3 ++ vars/mtaBuild.groovy | 16 +++++-- vars/newmanExecute.groovy | 9 ++++ vars/npmExecute.groovy | 13 ++++- vars/seleniumExecuteTests.groovy | 3 ++ vars/snykExecute.groovy | 13 ++++- vars/whitesourceExecuteScan.groovy | 15 +++++- 18 files changed, 344 insertions(+), 9 deletions(-) diff --git a/test/groovy/BatsExecuteTestsTest.groovy b/test/groovy/BatsExecuteTestsTest.groovy index ab284cdb2..66adf427c 100644 --- a/test/groovy/BatsExecuteTestsTest.groovy +++ b/test/groovy/BatsExecuteTestsTest.groovy @@ -70,6 +70,32 @@ class BatsExecuteTestsTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[batsExecuteTests:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.batsExecuteTests( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testTap() { stepRule.step.batsExecuteTests( diff --git a/test/groovy/GaugeExecuteTestsTest.groovy b/test/groovy/GaugeExecuteTestsTest.groovy index 2c61e0855..f0883e5f0 100644 --- a/test/groovy/GaugeExecuteTestsTest.groovy +++ b/test/groovy/GaugeExecuteTestsTest.groovy @@ -12,6 +12,7 @@ class GaugeExecuteTestsTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsDockerExecuteRule dockerExecuteRule = new JenkinsDockerExecuteRule(this) private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) private ExpectedException thrown = ExpectedException.none() @@ -21,6 +22,7 @@ class GaugeExecuteTestsTest extends BasePiperTest { .around(new JenkinsReadYamlRule(this)) .around(shellRule) .around(loggingRule) + .around(dockerExecuteRule) .around(environmentRule) .around(stepRule) .around(thrown) @@ -67,6 +69,32 @@ class GaugeExecuteTestsTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['HUB':'', 'HUB_URL':'', 'env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[gaugeExecuteTests:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.gaugeExecuteTests( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == seleniumParams.dockerImage + assert expectedOptions == seleniumParams.dockerOptions + assert expectedEnvVars.equals(seleniumParams.dockerEnvVars) + assert expectedWorkspace == seleniumParams.dockerWorkspace + } + @Test void testExecuteGaugeNode() throws Exception { stepRule.step.gaugeExecuteTests( diff --git a/test/groovy/KarmaExecuteTestsTest.groovy b/test/groovy/KarmaExecuteTestsTest.groovy index c40af9a32..69293773d 100644 --- a/test/groovy/KarmaExecuteTestsTest.groovy +++ b/test/groovy/KarmaExecuteTestsTest.groovy @@ -53,6 +53,32 @@ class KarmaExecuteTestsTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['NO_PROXY':'', 'no_proxy':'', 'env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[karmaExecuteTests:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.karmaExecuteTests( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == seleniumParams.dockerImage + assert expectedOptions == seleniumParams.dockerOptions + assert expectedEnvVars.equals(seleniumParams.dockerEnvVars) + assert expectedWorkspace == seleniumParams.dockerWorkspace + } + @Test void testMultiModules() throws Exception { stepRule.step.karmaExecuteTests( diff --git a/test/groovy/MtaBuildTest.groovy b/test/groovy/MtaBuildTest.groovy index 0cc67ea91..4d6194409 100644 --- a/test/groovy/MtaBuildTest.groovy +++ b/test/groovy/MtaBuildTest.groovy @@ -169,6 +169,29 @@ public class MtaBuildTest extends BasePiperTest { assert shellRule.shell.find(){ c -> c.contains('java -jar /opt/sap/mta/lib/mta.jar --mtar com.mycompany.northwind.mtar --build-target=NEO build')} } + @Test + void dockerFromCustomStepConfigurationTest() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '-w /path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[mtaBuild:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.mtaBuild(script: nullScript) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void canConfigureDockerImage() { diff --git a/test/groovy/NewmanExecuteTest.groovy b/test/groovy/NewmanExecuteTest.groovy index 5e3d82a16..f8be4f859 100644 --- a/test/groovy/NewmanExecuteTest.groovy +++ b/test/groovy/NewmanExecuteTest.groovy @@ -77,6 +77,32 @@ class NewmanExecuteTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[newmanExecute:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.newmanExecute( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testGlobalInstall() throws Exception { stepRule.step.newmanExecute( diff --git a/test/groovy/NpmExecuteTest.groovy b/test/groovy/NpmExecuteTest.groovy index 610f23ad3..eebcaea2f 100644 --- a/test/groovy/NpmExecuteTest.groovy +++ b/test/groovy/NpmExecuteTest.groovy @@ -40,6 +40,32 @@ class NpmExecuteTest extends BasePiperTest { assertEquals 'node:8-stretch', dockerExecuteRule.dockerParams.dockerImage } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[npmExecute:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.npmExecute( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testNpmExecuteWithClosure() { stepRule.step.npmExecute(script: nullScript, dockerImage: 'node:8-stretch', npmCommand: 'run build') { } diff --git a/test/groovy/SeleniumExecuteTestsTest.groovy b/test/groovy/SeleniumExecuteTestsTest.groovy index 5b1872838..381b58145 100644 --- a/test/groovy/SeleniumExecuteTestsTest.groovy +++ b/test/groovy/SeleniumExecuteTestsTest.groovy @@ -57,6 +57,33 @@ class SeleniumExecuteTestsTest extends BasePiperTest { assertThat(dockerExecuteRule.dockerParams.sidecarVolumeBind, is(['/dev/shm': '/dev/shm'])) } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + nullScript.commonPipelineEnvironment.configuration = [steps:[seleniumExecuteTests:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + + stepRule.step.seleniumExecuteTests( + script: nullScript, + juStabUtils: utils + ) { + } + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testExecuteSeleniumCustomBuildTool() { stepRule.step.seleniumExecuteTests( diff --git a/test/groovy/SnykExecuteTest.groovy b/test/groovy/SnykExecuteTest.groovy index d9b3e456b..1833c238d 100644 --- a/test/groovy/SnykExecuteTest.groovy +++ b/test/groovy/SnykExecuteTest.groovy @@ -17,6 +17,8 @@ import util.JenkinsStepRule import util.JenkinsLoggingRule import util.Rules +import com.sap.piper.MapUtils + class SnykExecuteTest extends BasePiperTest { private ExpectedException thrown = ExpectedException.none() private JenkinsDockerExecuteRule dockerExecuteRule = new JenkinsDockerExecuteRule(this) @@ -94,6 +96,35 @@ class SnykExecuteTest extends BasePiperTest { assertThat(dockerExecuteRule.dockerParams.stashContent, hasItem('opensourceConfiguration')) } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['SNYK_TOKEN':'', 'env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + + nullScript.commonPipelineEnvironment.configuration = MapUtils.merge( + nullScript.commonPipelineEnvironment.configuration, + [steps:[snykExecute:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]]) + + stepRule.step.snykExecute( + script: nullScript, + juStabUtils: utils + ) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testScanTypeNpm() throws Exception { stepRule.step.snykExecute( diff --git a/test/groovy/WhitesourceExecuteScanTest.groovy b/test/groovy/WhitesourceExecuteScanTest.groovy index 59e440692..b3f737d0a 100644 --- a/test/groovy/WhitesourceExecuteScanTest.groovy +++ b/test/groovy/WhitesourceExecuteScanTest.groovy @@ -2,6 +2,7 @@ import com.sap.piper.DescriptorUtils import com.sap.piper.JsonUtils import com.sap.piper.integration.WhitesourceOrgAdminRepository import com.sap.piper.integration.WhitesourceRepository +import com.sap.piper.MapUtils import hudson.AbortException import org.hamcrest.Matchers import org.junit.Assert @@ -100,6 +101,53 @@ class WhitesourceExecuteScanTest extends BasePiperTest { nullScript.commonPipelineEnvironment.configuration['steps']['whitesourceExecuteScan']['userTokenCredentialsId'] = 'ID-123456789' } + @Test + void testDockerFromCustomStepConfiguration() { + + def expectedImage = 'image:test' + def expectedEnvVars = ['env1': 'value1', 'env2': 'value2'] + def expectedOptions = '--opt1=val1 --opt2=val2 --opt3' + def expectedWorkspace = '/path/to/workspace' + + helper.registerAllowedMethod("readProperties", [Map], { + def result = new Properties() + result.putAll([ + "apiKey": "b39d1328-52e2-42e3-98f0-932709daf3f0", + "productName": "SHC - Piper", + "checkPolicies": "true", + "projectName": "python-test", + "projectVersion": "1.0.0" + ]) + return result + }) + + nullScript.commonPipelineEnvironment.configuration = + MapUtils.merge(nullScript.commonPipelineEnvironment.configuration, + [steps:[whitesourceExecuteScan:[ + dockerImage: expectedImage, + dockerOptions: expectedOptions, + dockerEnvVars: expectedEnvVars, + dockerWorkspace: expectedWorkspace + ]]] + ) + + stepRule.step.whitesourceExecuteScan([ + script : nullScript, + whitesourceRepositoryStub : whitesourceStub, + whitesourceOrgAdminRepositoryStub : whitesourceOrgAdminRepositoryStub, + descriptorUtilsStub : descriptorUtilsStub, + scanType : 'maven', + juStabUtils : utils, + orgToken : 'testOrgToken', + whitesourceProductName : 'testProduct' + ]) + + assert expectedImage == dockerExecuteRule.dockerParams.dockerImage + assert expectedOptions == dockerExecuteRule.dockerParams.dockerOptions + assert expectedEnvVars.equals(dockerExecuteRule.dockerParams.dockerEnvVars) + assert expectedWorkspace == dockerExecuteRule.dockerParams.dockerWorkspace + } + @Test void testMaven() { helper.registerAllowedMethod("readProperties", [Map], { diff --git a/vars/batsExecuteTests.groovy b/vars/batsExecuteTests.groovy index 5f59122f3..099fd570c 100644 --- a/vars/batsExecuteTests.groovy +++ b/vars/batsExecuteTests.groovy @@ -16,6 +16,10 @@ import groovy.transform.Field /** @see dockerExecute */ 'dockerImage', /** @see dockerExecute */ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute */ 'dockerWorkspace', /** @see dockerExecute */ 'stashContent', @@ -97,7 +101,14 @@ void call(Map parameters = [:]) { } finally { sh "cat 'TEST-${config.testPackage}.tap'" if (config.outputFormat == 'junit') { - dockerExecute(script: script, dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent) { + dockerExecute( + script: script, + dockerImage: config.dockerImage, + dockerEnvVars: config.dockerEnvVars, + dockerOptions: config.dockerOptions, + dockerWorkspace: config.dockerWorkspace, + stashContent: config.stashContent + ) { sh "NPM_CONFIG_PREFIX=~/.npm-global npm install tap-xunit -g" sh "cat 'TEST-${config.testPackage}.tap' | PATH=\$PATH:~/.npm-global/bin tap-xunit --package='${config.testPackage}' > TEST-${config.testPackage}.xml" } diff --git a/vars/gaugeExecuteTests.groovy b/vars/gaugeExecuteTests.groovy index 1eacde6ae..d07a5f3d4 100644 --- a/vars/gaugeExecuteTests.groovy +++ b/vars/gaugeExecuteTests.groovy @@ -24,6 +24,8 @@ import groovy.transform.Field 'dockerImage', /** @see dockerExecute*/ 'dockerName', + /** @see dockerExecute */ + 'dockerOptions', /** @see dockerExecute*/ 'dockerWorkspace', /** @@ -100,6 +102,8 @@ void call(Map parameters = [:]) { .mixin(parameters, PARAMETER_KEYS) .dependingOn('buildTool').mixin('dockerImage') .dependingOn('buildTool').mixin('dockerName') + .dependingOn('buildTool').mixin('dockerOptions') + .dependingOn('buildTool').mixin('dockerEnvVars') .dependingOn('buildTool').mixin('dockerWorkspace') .dependingOn('buildTool').mixin('languageRunner') .dependingOn('buildTool').mixin('runCommand') @@ -127,9 +131,10 @@ void call(Map parameters = [:]) { seleniumExecuteTests ( script: script, buildTool: config.buildTool, - dockerEnvVars: config.dockerEnvVars, dockerImage: config.dockerImage, dockerName: config.dockerName, + dockerEnvVars: config.dockerEnvVars, + dockerOptions: config.dockerOptions, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent ) { diff --git a/vars/karmaExecuteTests.groovy b/vars/karmaExecuteTests.groovy index 6202a7e11..7c4e86ced 100644 --- a/vars/karmaExecuteTests.groovy +++ b/vars/karmaExecuteTests.groovy @@ -30,6 +30,8 @@ import groovy.transform.Field * Specifies a dedicated user home directory for the container which will be passed as value for environment variable `HOME`. */ 'dockerWorkspace', + /** @see dockerExecute */ + 'dockerOptions', /** * With `failOnError` the behavior in case tests fail can be defined. * @possibleValues `true`, `false` @@ -95,6 +97,7 @@ void call(Map parameters = [:]) { dockerImage: config.dockerImage, dockerName: config.dockerName, dockerWorkspace: config.dockerWorkspace, + dockerOptions: config.dockerOptions, failOnError: config.failOnError, sidecarEnvVars: config.sidecarEnvVars, sidecarImage: config.sidecarImage, diff --git a/vars/mtaBuild.groovy b/vars/mtaBuild.groovy index 0253c8db2..07fcba90d 100644 --- a/vars/mtaBuild.groovy +++ b/vars/mtaBuild.groovy @@ -21,6 +21,12 @@ import static com.sap.piper.Utils.downloadSettingsFromUrl 'buildTarget', /** @see dockerExecute */ 'dockerImage', + /** @see dockerExecute */ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute */ + 'dockerWorkspace', /** The path to the extension descriptor file.*/ 'extension', /** @@ -34,8 +40,6 @@ import static com.sap.piper.Utils.downloadSettingsFromUrl 'projectSettingsFile' ] @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS.plus([ - /** @see dockerExecute */ - 'dockerOptions', /** Url to the npm registry that should be used for installing npm dependencies.*/ 'defaultNpmRegistry' ]) @@ -64,7 +68,13 @@ void call(Map parameters = [:]) { stepParam1: parameters?.script == null ], configuration) - dockerExecute(script: script, dockerImage: configuration.dockerImage, dockerOptions: configuration.dockerOptions) { + dockerExecute( + script: script, + dockerImage: configuration.dockerImage, + dockerEnvVars: configuration.dockerEnvVars, + dockerOptions: configuration.dockerOptions, + dockerWorkspace: configuration.dockerWorkspace + ) { String projectSettingsFile = configuration.projectSettingsFile?.trim() if (projectSettingsFile) { diff --git a/vars/newmanExecute.groovy b/vars/newmanExecute.groovy index f58a1eb53..3ac35c408 100644 --- a/vars/newmanExecute.groovy +++ b/vars/newmanExecute.groovy @@ -14,6 +14,12 @@ import groovy.transform.Field @Field Set STEP_CONFIG_KEYS = [ /** @see dockerExecute */ 'dockerImage', + /** @see dockerExecute*/ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute*/ + 'dockerWorkspace', /** * Defines the behavior, in case tests fail. * @possibleValues `true`, `false` @@ -103,6 +109,9 @@ void call(Map parameters = [:]) { dockerExecute( script: script, dockerImage: config.dockerImage, + dockerEnvVars: config.dockerEnvVars, + dockerOptions: config.dockerOptions, + dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent ) { sh "NPM_CONFIG_PREFIX=~/.npm-global ${config.newmanInstallCommand}" diff --git a/vars/npmExecute.groovy b/vars/npmExecute.groovy index 06e980737..52aa94359 100644 --- a/vars/npmExecute.groovy +++ b/vars/npmExecute.groovy @@ -11,6 +11,12 @@ import groovy.transform.Field * Name of the docker image that should be used, in which node should be installed and configured. Default value is 'node:8-stretch'. */ 'dockerImage', + /** @see dockerExecute*/ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute*/ + 'dockerWorkspace', /** * URL of default NPM registry */ @@ -53,7 +59,12 @@ void call(Map parameters = [:], body = null) { if (!fileExists('package.json')) { error "[${STEP_NAME}] package.json is not found." } - dockerExecute(script: script, dockerImage: configuration.dockerImage, dockerOptions: configuration.dockerOptions) { + dockerExecute(script: script, + dockerImage: configuration.dockerImage, + dockerEnvVars: configuration.dockerEnvVars, + dockerOptions: configuration.dockerOptions, + dockerWorkspace: configuration.dockerWorkspace + ) { if (configuration.defaultNpmRegistry) { sh "npm config set registry ${configuration.defaultNpmRegistry}" } diff --git a/vars/seleniumExecuteTests.groovy b/vars/seleniumExecuteTests.groovy index 8f5f3b197..c1a889328 100644 --- a/vars/seleniumExecuteTests.groovy +++ b/vars/seleniumExecuteTests.groovy @@ -26,6 +26,8 @@ import groovy.text.SimpleTemplateEngine /** @see dockerExecute */ 'dockerName', /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute */ 'dockerWorkspace', /** * With `failOnError` the behavior in case tests fail can be defined. @@ -103,6 +105,7 @@ void call(Map parameters = [:], Closure body) { dockerEnvVars: config.dockerEnvVars, dockerImage: config.dockerImage, dockerName: config.dockerName, + dockerOptions: config.dockerOptions, dockerWorkspace: config.dockerWorkspace, sidecarEnvVars: config.sidecarEnvVars, sidecarImage: config.sidecarImage, diff --git a/vars/snykExecute.groovy b/vars/snykExecute.groovy index d82c383d2..72fbeefee 100644 --- a/vars/snykExecute.groovy +++ b/vars/snykExecute.groovy @@ -4,6 +4,7 @@ import com.sap.piper.ConfigurationHelper import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils import com.sap.piper.mta.MtaMultiplexer +import com.sap.piper.MapUtils import groovy.transform.Field @@ -23,6 +24,12 @@ import groovy.transform.Field 'buildDescriptorFile', /** @see dockerExecute */ 'dockerImage', + /** @see dockerExecute*/ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', + /** @see dockerExecute*/ + 'dockerWorkspace', /** * Only scanType 'mta': Exclude modules from MTA projects. */ @@ -103,8 +110,10 @@ void call(Map parameters = [:]) { dockerExecute( script: script, dockerImage: config.dockerImage, - stashContent: config.stashContent, - dockerEnvVars: ['SNYK_TOKEN': token] + dockerEnvVars: MapUtils.merge(['SNYK_TOKEN': token],config.dockerEnvVars?:[:]), + dockerWorkspace: config.dockerWorkspace, + dockerOptions: config.dockerOptions, + stashContent: config.stashContent ) { // install Snyk sh 'npm install snyk --global --quiet' diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index c7c0cfd21..10381775a 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -117,6 +117,10 @@ import static com.sap.piper.Prerequisites.checkScript * Docker workspace to be used for scanning. */ 'dockerWorkspace', + /** @see dockerExecute*/ + 'dockerEnvVars', + /** @see dockerExecute */ + 'dockerOptions', /** * Whether license compliance is considered and reported as part of the assessment. * @possibleValues `true`, `false` @@ -246,6 +250,8 @@ void call(Map parameters = [:]) { .dependingOn('scanType').mixin('buildDescriptorFile') .dependingOn('scanType').mixin('dockerImage') .dependingOn('scanType').mixin('dockerWorkspace') + .dependingOn('scanType').mixin('dockerOptions') + .dependingOn('scanType').mixin('dockerEnvVars') .dependingOn('scanType').mixin('stashContent') .dependingOn('scanType').mixin('whitesource/configFilePath') .dependingOn('scanType').mixin('whitesource/installCommand') @@ -369,7 +375,14 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU script.commonPipelineEnvironment.getValue('whitesourceProjectNames').add(projectName) WhitesourceConfigurationHelper.extendUAConfigurationFile(script, utils, config, path) - dockerExecute(script: script, dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent) { + dockerExecute( + script: script, + dockerImage: config.dockerImage, + dockerEnvVars: config.dockerEnvVars, + dockerOptions: config.dockerOptions, + dockerWorkspace: config.dockerWorkspace, + stashContent: config.stashContent + ) { if (config.whitesource.agentDownloadUrl) { def agentDownloadUrl = new GStringTemplateEngine().createTemplate(config.whitesource.agentDownloadUrl).make([config: config]).toString() //if agentDownloadUrl empty, rely on dockerImage to contain unifiedAgent correctly set up and available From db8f9d0f0775c50afff2c1b445584669ec321dee Mon Sep 17 00:00:00 2001 From: Roland Stengel Date: Thu, 25 Jul 2019 12:12:34 +0200 Subject: [PATCH 014/141] harmonize docker configuration properties fixes --- test/groovy/GaugeExecuteTestsTest.groovy | 2 -- vars/npmExecute.groovy | 4 ++-- vars/whitesourceExecuteScan.groovy | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/groovy/GaugeExecuteTestsTest.groovy b/test/groovy/GaugeExecuteTestsTest.groovy index f0883e5f0..31e60c03a 100644 --- a/test/groovy/GaugeExecuteTestsTest.groovy +++ b/test/groovy/GaugeExecuteTestsTest.groovy @@ -12,7 +12,6 @@ class GaugeExecuteTestsTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) - private JenkinsDockerExecuteRule dockerExecuteRule = new JenkinsDockerExecuteRule(this) private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) private ExpectedException thrown = ExpectedException.none() @@ -22,7 +21,6 @@ class GaugeExecuteTestsTest extends BasePiperTest { .around(new JenkinsReadYamlRule(this)) .around(shellRule) .around(loggingRule) - .around(dockerExecuteRule) .around(environmentRule) .around(stepRule) .around(thrown) diff --git a/vars/npmExecute.groovy b/vars/npmExecute.groovy index 52aa94359..30baf998c 100644 --- a/vars/npmExecute.groovy +++ b/vars/npmExecute.groovy @@ -59,8 +59,8 @@ void call(Map parameters = [:], body = null) { if (!fileExists('package.json')) { error "[${STEP_NAME}] package.json is not found." } - dockerExecute(script: script, - dockerImage: configuration.dockerImage, + dockerExecute(script: script, + dockerImage: configuration.dockerImage, dockerEnvVars: configuration.dockerEnvVars, dockerOptions: configuration.dockerOptions, dockerWorkspace: configuration.dockerWorkspace diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index 10381775a..d4a975a23 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -376,7 +376,7 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU WhitesourceConfigurationHelper.extendUAConfigurationFile(script, utils, config, path) dockerExecute( - script: script, + script: script, dockerImage: config.dockerImage, dockerEnvVars: config.dockerEnvVars, dockerOptions: config.dockerOptions, From e8821c2b90ab933d8fe3a2ddb2c1977f1bcfa42c Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Thu, 25 Jul 2019 15:27:49 +0200 Subject: [PATCH 015/141] Make library versioning more flexible (#806) There is a possibility with maven to inject the version number into the build (see https://maven.apache.org/maven-ci-friendly.html). This will allow us to publish regular releases without permanent PRs for version updates. --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 402a28300..6231dd661 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ 4.0.0 com.sap.cp.jenkins jenkins-library - 0.11 + ${revision} SAP CP Piper Library Shared library containing steps and utilities to set up continuous deployment processes for SAP technologies. @@ -40,6 +40,7 @@ + 0-SNAPSHOT true 2.32.3 2.5 @@ -47,7 +48,6 @@ 8 - From 153dbf2a7f874563734eb8aeeabde8b4e8e93e22 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 26 Jul 2019 14:03:20 +0200 Subject: [PATCH 016/141] githubPublishRelease - ensure proper JSON encoding (#807) So far some special characters have not been properly encoded when creating a release. This is addressed by using a new JsonUtils method now. --- src/com/sap/piper/JsonUtils.groovy | 5 +++++ test/groovy/GithubPublishReleaseTest.groovy | 1 + vars/githubPublishRelease.groovy | 14 +++++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/com/sap/piper/JsonUtils.groovy b/src/com/sap/piper/JsonUtils.groovy index 840205561..541f1980e 100644 --- a/src/com/sap/piper/JsonUtils.groovy +++ b/src/com/sap/piper/JsonUtils.groovy @@ -7,6 +7,11 @@ String groovyObjectToPrettyJsonString(object) { return groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(object)) } +@NonCPS +String groovyObjectToJsonString(object) { + return groovy.json.JsonOutput.toJson(object) +} + @NonCPS def jsonStringToGroovyObject(text) { return new groovy.json.JsonSlurperClassic().parseText(text) diff --git a/test/groovy/GithubPublishReleaseTest.groovy b/test/groovy/GithubPublishReleaseTest.groovy index 5ed463e6c..c1b0f1785 100644 --- a/test/groovy/GithubPublishReleaseTest.groovy +++ b/test/groovy/GithubPublishReleaseTest.groovy @@ -213,4 +213,5 @@ class GithubPublishReleaseTest extends BasePiperTest { assertThat(stepRule.step.isExcluded(item, ['won\'t fix']), is(false)) assertJobStatusSuccess() } + } diff --git a/vars/githubPublishRelease.groovy b/vars/githubPublishRelease.groovy index cbda38efc..2b1c5bec8 100644 --- a/vars/githubPublishRelease.groovy +++ b/vars/githubPublishRelease.groovy @@ -1,3 +1,5 @@ +import com.sap.piper.JsonUtils + import static com.sap.piper.Prerequisites.checkScript import com.sap.piper.GenerateDocumentation @@ -146,9 +148,15 @@ String addDeltaToLastRelease(config, latestTag){ } void postNewRelease(config, TOKEN, releaseBody){ - releaseBody = releaseBody.replace('"', '\\"') - //write release information - def data = "{\"tag_name\": \"${config.version}\",\"target_commitish\": \"master\",\"name\": \"${config.version}\",\"body\": \"${releaseBody}\",\"draft\": false,\"prerelease\": false}" + Map messageBody = [ + tag_name: "${config.version}", + target_commitish: 'master', + name: "${config.version}", + body: releaseBody, + draft: false, + prerelease: false + ] + def data = new JsonUtils().groovyObjectToJsonString(messageBody) try { httpRequest httpMode: 'POST', requestBody: data, url: "${config.githubApiUrl}/repos/${config.githubOrg}/${config.githubRepo}/releases?access_token=${TOKEN}" } catch (e) { From 0c3e5f1ea9cc8da566e42a9fd02a63ef87e82d06 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 26 Jul 2019 17:40:22 +0200 Subject: [PATCH 017/141] use new unstable step to better visualize pipeline errors (#804) With https://jenkins.io/blog/2019/07/05/jenkins-pipeline-stage-result-visualization-improvements/ it has been made possible to allow for a better visualization in case certain pipeline stages are 'UNSTABLE' This is about using the new feature if available with a fall-back to old behavior. --- test/groovy/HandlePipelineStepErrorsTest.groovy | 13 +++++++++++++ vars/handlePipelineStepErrors.groovy | 17 +++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/test/groovy/HandlePipelineStepErrorsTest.groovy b/test/groovy/HandlePipelineStepErrorsTest.groovy index 6faebbbf7..17352c51a 100644 --- a/test/groovy/HandlePipelineStepErrorsTest.groovy +++ b/test/groovy/HandlePipelineStepErrorsTest.groovy @@ -86,6 +86,9 @@ class HandlePipelineStepErrorsTest extends BasePiperTest { @Test void testHandleErrorsIgnoreFailure() { def errorOccured = false + helper.registerAllowedMethod('unstable', [String.class], {s -> + nullScript.currentBuild.result = 'UNSTABLE' + }) try { stepRule.step.handlePipelineStepErrors([ stepName: 'test', @@ -127,6 +130,10 @@ class HandlePipelineStepErrorsTest extends BasePiperTest { @Test void testHandleErrorsIgnoreFailureNoScript() { def errorOccured = false + helper.registerAllowedMethod('unstable', [String.class], {s -> + //test behavior in case plugina are not yet up to date + throw new java.lang.NoSuchMethodError('No such DSL method \'unstable\' found') + }) try { stepRule.step.handlePipelineStepErrors([ stepName: 'test', @@ -148,6 +155,11 @@ class HandlePipelineStepErrorsTest extends BasePiperTest { timeout = m.time throw new org.jenkinsci.plugins.workflow.steps.FlowInterruptedException(hudson.model.Result.ABORTED, new jenkins.model.CauseOfInterruption.UserInterruption('Test')) }) + String errorMsg + helper.registerAllowedMethod('unstable', [String.class], {s -> + nullScript.currentBuild.result = 'UNSTABLE' + errorMsg = s + }) stepRule.step.handlePipelineStepErrors([ stepName: 'test', @@ -159,5 +171,6 @@ class HandlePipelineStepErrorsTest extends BasePiperTest { } assertThat(timeout, is(10)) assertThat(nullScript.currentBuild.result, is('UNSTABLE')) + assertThat(errorMsg, is('[handlePipelineStepErrors] Error in step test - Build result set to \'UNSTABLE\'')) } } diff --git a/vars/handlePipelineStepErrors.groovy b/vars/handlePipelineStepErrors.groovy index 182424f8c..b78c94d8a 100644 --- a/vars/handlePipelineStepErrors.groovy +++ b/vars/handlePipelineStepErrors.groovy @@ -81,14 +81,19 @@ void call(Map parameters = [:], body) { throw ex } - if (config.stepParameters?.script) { - config.stepParameters?.script.currentBuild.result = 'UNSTABLE' - } else { - currentBuild.result = 'UNSTABLE' + def failureMessage = "[${STEP_NAME}] Error in step ${config.stepName} - Build result set to 'UNSTABLE'" + try { + //use new unstable feature if available: see https://jenkins.io/blog/2019/07/05/jenkins-pipeline-stage-result-visualization-improvements/ + unstable(failureMessage) + } catch (java.lang.NoSuchMethodError nmEx) { + if (config.stepParameters?.script) { + config.stepParameters?.script.currentBuild.result = 'UNSTABLE' + } else { + currentBuild.result = 'UNSTABLE' + } + echo failureMessage } - echo "[${STEP_NAME}] Error in step ${config.stepName} - Build result set to 'UNSTABLE'" - List unstableSteps = cpe?.getValue('unstableSteps') ?: [] if(!unstableSteps) { unstableSteps = [] From 7845e18f4d9629f382fdf5ceb92885281ed70e46 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Mon, 29 Jul 2019 10:17:56 +0200 Subject: [PATCH 018/141] fix NonCPS issues (#796) * remove NonCPS statement * remove NonCPS anntotation * fix typo * remove NonCPS anntotation * remove NonCPS anntotation * remove NonCPS anntotation * remove NonCPS anntotation * remove NonCPS anntotation * remove Iterable * remove mixins * add mixins * add mixins 2 * add mixins 3 * add NonCPS anntotation * remove tokenize * remove closure * remove closure * replace closure * use Object * use Object * use Object * remove object * remove object * add logic * change type * change type * remove NonCPS anntotation * remove NonCPS anntotation * add import --- src/com/sap/piper/ConfigurationHelper.groovy | 47 +++++++++----------- src/com/sap/piper/ConfigurationLoader.groovy | 10 ----- src/com/sap/piper/ConfigurationMerger.groovy | 3 -- src/com/sap/piper/DefaultValueCache.groovy | 4 -- src/com/sap/piper/MapUtils.groovy | 35 ++++++--------- src/com/sap/piper/analytics/Telemetry.groovy | 1 - vars/checksPublishResults.groovy | 2 - vars/testsPublishResults.groovy | 4 -- 8 files changed, 36 insertions(+), 70 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index 22ce65400..155f1fbe0 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -22,6 +22,7 @@ class ConfigurationHelper implements Serializable { private Script step private String name private Map validationResults = null + private String dependingOn private ConfigurationHelper(Script step, Map config){ this.config = config ?: [:] @@ -87,22 +88,25 @@ class ConfigurationHelper implements Serializable { return newConfig } - Map dependingOn(dependentKey){ - return [ - mixin: {key -> - def parts = tokenizeKey(key) - def targetMap = config - if(parts.size() > 1) { - key = parts.last() - parts.remove(key) - targetMap = getConfigPropertyNested(config, (parts as Iterable).join(SEPARATOR)) - } - def dependentValue = config[dependentKey] - if(targetMap[key] == null && dependentValue && config[dependentValue]) - targetMap[key] = config[dependentValue][key] - return this - } - ] + ConfigurationHelper mixin(String key){ + def parts = tokenizeKey(key) + def targetMap = config + if(parts.size() > 1) { + key = parts.last() + parts.remove(key) + targetMap = getConfigPropertyNested(config, parts.join(SEPARATOR)) + } + def dependentValue = config[dependingOn] + if(targetMap[key] == null && dependentValue && config[dependentValue]) + targetMap[key] = config[dependentValue][key] + + dependingOn = null + return this + } + + ConfigurationHelper dependingOn(dependentKey){ + dependingOn = dependentKey + return this } ConfigurationHelper addIfEmpty(key, value){ @@ -121,8 +125,6 @@ class ConfigurationHelper implements Serializable { return this } - @NonCPS // required because we have a closure in the - // method body that cannot be CPS transformed Map use(){ handleValidationFailures() MapUtils.traverse(config, { v -> (v instanceof GString) ? v.toString() : v }) @@ -130,8 +132,6 @@ class ConfigurationHelper implements Serializable { return MapUtils.deepCopy(config) } - - /* private */ def getConfigPropertyNested(key) { return getConfigPropertyNested(config, key) } @@ -143,7 +143,7 @@ class ConfigurationHelper implements Serializable { if (config[parts.head()] != null) { if (config[parts.head()] in Map && !parts.tail().isEmpty()) { - return getConfigPropertyNested(config[parts.head()], (parts.tail() as Iterable).join(SEPARATOR)) + return getConfigPropertyNested(config[parts.head()], parts.tail().join(SEPARATOR)) } if (config[parts.head()].class == String) { @@ -193,15 +193,12 @@ class ConfigurationHelper implements Serializable { return this } - @NonCPS private handleValidationFailures() { if(! validationResults) return if(validationResults.size() == 1) throw validationResults.values().first() - String msg = 'ERROR - NO VALUE AVAILABLE FOR: ' + - (validationResults.keySet().stream().collect() as Iterable).join(', ') + String msg = 'ERROR - NO VALUE AVAILABLE FOR: ' + validationResults.keySet().join(', ') IllegalArgumentException iae = new IllegalArgumentException(msg) validationResults.each { e -> iae.addSuppressed(e.value) } throw iae } - } diff --git a/src/com/sap/piper/ConfigurationLoader.groovy b/src/com/sap/piper/ConfigurationLoader.groovy index d537f15c0..27cbdfe18 100644 --- a/src/com/sap/piper/ConfigurationLoader.groovy +++ b/src/com/sap/piper/ConfigurationLoader.groovy @@ -1,30 +1,23 @@ package com.sap.piper -import com.cloudbees.groovy.cps.NonCPS - @API(deprecated = true) class ConfigurationLoader implements Serializable { - @NonCPS static Map stepConfiguration(script, String stepName) { return loadConfiguration(script, 'steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) } - @NonCPS static Map stageConfiguration(script, String stageName) { return loadConfiguration(script, 'stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) } - @NonCPS static Map defaultStepConfiguration(script, String stepName) { return loadConfiguration(script, 'steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) } - @NonCPS static Map defaultStageConfiguration(script, String stageName) { return loadConfiguration(script, 'stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) } - @NonCPS static Map generalConfiguration(script){ try { return script?.commonPipelineEnvironment?.configuration?.general ?: [:] @@ -33,17 +26,14 @@ class ConfigurationLoader implements Serializable { } } - @NonCPS static Map defaultGeneralConfiguration(script){ return DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] } - @NonCPS static Map postActionConfiguration(script, String actionName){ return loadConfiguration(script, 'postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) } - @NonCPS private static Map loadConfiguration(script, String type, String entryName, ConfigurationType configType){ switch (configType) { case ConfigurationType.CUSTOM_CONFIGURATION: diff --git a/src/com/sap/piper/ConfigurationMerger.groovy b/src/com/sap/piper/ConfigurationMerger.groovy index 20c58cc14..7e53601fd 100644 --- a/src/com/sap/piper/ConfigurationMerger.groovy +++ b/src/com/sap/piper/ConfigurationMerger.groovy @@ -1,10 +1,8 @@ package com.sap.piper -import com.cloudbees.groovy.cps.NonCPS @API(deprecated = true) class ConfigurationMerger { - @NonCPS static Map merge(Map configs, Set configKeys, Map defaults) { Map filteredConfig = configKeys?configs.subMap(configKeys):configs @@ -12,7 +10,6 @@ class ConfigurationMerger { MapUtils.pruneNulls(filteredConfig)) } - @NonCPS static Map merge( Map parameters, Set parameterKeys, Map configuration, Set configurationKeys, diff --git a/src/com/sap/piper/DefaultValueCache.groovy b/src/com/sap/piper/DefaultValueCache.groovy index 8fac2647c..bab5ba47d 100644 --- a/src/com/sap/piper/DefaultValueCache.groovy +++ b/src/com/sap/piper/DefaultValueCache.groovy @@ -2,8 +2,6 @@ package com.sap.piper import com.sap.piper.MapUtils -import com.cloudbees.groovy.cps.NonCPS - @API class DefaultValueCache implements Serializable { private static DefaultValueCache instance @@ -14,7 +12,6 @@ class DefaultValueCache implements Serializable { this.defaultValues = defaultValues } - @NonCPS static getInstance(){ return instance } @@ -23,7 +20,6 @@ class DefaultValueCache implements Serializable { instance = new DefaultValueCache(defaultValues) } - @NonCPS Map getDefaultValues(){ return defaultValues } diff --git a/src/com/sap/piper/MapUtils.groovy b/src/com/sap/piper/MapUtils.groovy index 657d82215..938d0caf5 100644 --- a/src/com/sap/piper/MapUtils.groovy +++ b/src/com/sap/piper/MapUtils.groovy @@ -1,30 +1,25 @@ package com.sap.piper -import com.cloudbees.groovy.cps.NonCPS - class MapUtils implements Serializable { - @NonCPS static boolean isMap(object){ return object in Map } - @NonCPS static Map pruneNulls(Map m) { Map result = [:] m = m ?: [:] - for(def e : m.entrySet()) - if(isMap(e.value)) - result[e.key] = pruneNulls(e.value) - else if(e.value != null) - result[e.key] = e.value + m.each { key, value -> + if(isMap(value)) + result[key] = pruneNulls(value) + else if(value != null) + result[key] = value + } return result } - - @NonCPS static Map merge(Map base, Map overlay) { Map result = [:] @@ -33,9 +28,9 @@ class MapUtils implements Serializable { result.putAll(base) - for(def e : overlay.entrySet()) - result[e.key] = isMap(e.value) ? merge(base[e.key], e.value) : e.value - + overlay.each { key, value -> + result[key] = isMap(value) ? merge(base[key], value) : value + } return result } @@ -46,18 +41,16 @@ class MapUtils implements Serializable { * in m in a recursive manner. * @param strategy Strategy applied to all non-map entries */ - @NonCPS static void traverse(Map m, Closure strategy) { def updates = [:] - for(def e : m.entrySet()) { - if(isMap(e.value)) { - traverse(e.getValue(), strategy) - } - else { + m.each { key, value -> + if(isMap(value)) { + traverse(value, strategy) + } else { // do not update the map while it is traversed. Depending // on the map implementation the behavior is undefined. - updates.put(e.getKey(), strategy(e.getValue())) + updates.put(key, strategy(value)) } } m.putAll(updates) diff --git a/src/com/sap/piper/analytics/Telemetry.groovy b/src/com/sap/piper/analytics/Telemetry.groovy index 4bb19bb72..370db5f2b 100644 --- a/src/com/sap/piper/analytics/Telemetry.groovy +++ b/src/com/sap/piper/analytics/Telemetry.groovy @@ -12,7 +12,6 @@ class Telemetry implements Serializable{ protected Telemetry(){} - @NonCPS protected static Telemetry getInstance(){ if(!instance) { instance = new Telemetry() diff --git a/vars/checksPublishResults.groovy b/vars/checksPublishResults.groovy index 7c09c42bf..4248c0edc 100644 --- a/vars/checksPublishResults.groovy +++ b/vars/checksPublishResults.groovy @@ -181,7 +181,6 @@ def createCommonOptionsMap(publisherName, settings){ return result } -@NonCPS def prepare(parameters){ // ensure tool maps are initialized correctly for(String tool : TOOLS){ @@ -190,7 +189,6 @@ def prepare(parameters){ return parameters } -@NonCPS def toMap(parameter){ if(MapUtils.isMap(parameter)) parameter.put('active', parameter.active == null?true:parameter.active) diff --git a/vars/testsPublishResults.groovy b/vars/testsPublishResults.groovy index ab4f78a0b..cdf1de290 100644 --- a/vars/testsPublishResults.groovy +++ b/vars/testsPublishResults.groovy @@ -1,7 +1,5 @@ import static com.sap.piper.Prerequisites.checkScript -import com.cloudbees.groovy.cps.NonCPS - import com.sap.piper.GenerateDocumentation import com.sap.piper.ConfigurationHelper import com.sap.piper.JenkinsUtils @@ -173,7 +171,6 @@ def archiveResults(archive, pattern, allowEmpty) { } } -@NonCPS def prepare(parameters){ // ensure tool maps are initialized correctly for(String tool : TOOLS){ @@ -182,7 +179,6 @@ def prepare(parameters){ return parameters } -@NonCPS def toMap(parameters){ if(MapUtils.isMap(parameters)) parameters.put('active', parameters.active == null?true:parameters.active) From ca88e10a7b7177432b9bca82f19e9fc345ce7223 Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Tue, 30 Jul 2019 13:41:58 +0200 Subject: [PATCH 019/141] Ensure stacktrace being printed in case of errors --- documentation/bin/createDocu.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/bin/createDocu.groovy b/documentation/bin/createDocu.groovy index e8068e020..32bdad344 100644 --- a/documentation/bin/createDocu.groovy +++ b/documentation/bin/createDocu.groovy @@ -672,7 +672,9 @@ for (step in steps) { stepDescriptors."${step}" = handleStep(step, gse) } catch(Exception e) { exceptionCaught = true - System.err << "${e.getClass().getName()} caught while handling step '${step}': ${e.getMessage()}.\n" + def writer = new StringWriter() + e.printStackTrace(new PrintWriter(writer)) + System.err << "${e.getClass().getName()} caught while handling step '${step}': ${e.getMessage()}.\n${writer.toString()}\n" } } From 073ce90fc9175a430d6f1480631ad62edb565a2d Mon Sep 17 00:00:00 2001 From: Oliver Feldmann Date: Wed, 31 Jul 2019 10:39:18 +0200 Subject: [PATCH 020/141] Add cds test case (#810) * Add cds test case * Fix typo Co-Authored-By: Christoph Szymanski --- consumer-test/jenkins.yml | 6 ++++++ consumer-test/testCases/scs/cap.yml | 7 +++++++ 2 files changed, 13 insertions(+) create mode 100644 consumer-test/testCases/scs/cap.yml diff --git a/consumer-test/jenkins.yml b/consumer-test/jenkins.yml index 546029dcf..5ddff5508 100644 --- a/consumer-test/jenkins.yml +++ b/consumer-test/jenkins.yml @@ -33,3 +33,9 @@ credentials: username: ${NEO_DEPLOY_USERNAME} password: ${NEO_DEPLOY_PASSWORD} description: "SAP CP NEO Trail account for test deployment" + - usernamePassword: + scope: GLOBAL + id: "cf_deploy" + username: ${CX_INFRA_IT_CF_USERNAME} + password: ${CX_INFRA_IT_CF_PASSWORD} + description: "SAP CP CF Trial account for test deployment" diff --git a/consumer-test/testCases/scs/cap.yml b/consumer-test/testCases/scs/cap.yml new file mode 100644 index 000000000..f83510107 --- /dev/null +++ b/consumer-test/testCases/scs/cap.yml @@ -0,0 +1,7 @@ +# Test case configuration +referenceAppRepo: + url: "https://github.com/piper-validation/mta-sample-app.git" + branch: "piper-test-cap" +deployCredentialEnv: + username: "CX_INFRA_IT_CF_USERNAME" + password: "CX_INFRA_IT_CF_PASSWORD" From af5c16ef46d4ef3771f90c36ce809a2042753d9b Mon Sep 17 00:00:00 2001 From: Oliver Feldmann Date: Wed, 31 Jul 2019 12:22:26 +0200 Subject: [PATCH 021/141] setupCommonPipelineEnvironment: support yaml config file ending (#811) * Allow for yaml file ending * Format code --- .../SetupCommonPipelineEnvironmentTest.groovy | 24 +++++++++++++------ vars/setupCommonPipelineEnvironment.groovy | 3 +++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/test/groovy/SetupCommonPipelineEnvironmentTest.groovy b/test/groovy/SetupCommonPipelineEnvironmentTest.groovy index 4e46435ad..52d53ca83 100644 --- a/test/groovy/SetupCommonPipelineEnvironmentTest.groovy +++ b/test/groovy/SetupCommonPipelineEnvironmentTest.groovy @@ -3,18 +3,13 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.yaml.snakeyaml.Yaml - -import com.sap.piper.Utils - import util.BasePiperTest -import util.Rules -import util.JenkinsReadYamlRule import util.JenkinsStepRule +import util.Rules import static org.junit.Assert.assertEquals import static org.junit.Assert.assertNotNull - class SetupCommonPipelineEnvironmentTest extends BasePiperTest { def usedConfigFile @@ -33,7 +28,7 @@ class SetupCommonPipelineEnvironmentTest extends BasePiperTest { helper.registerAllowedMethod("readYaml", [Map], { Map parameters -> Yaml yamlParser = new Yaml() - if(parameters.text) { + if (parameters.text) { return yamlParser.load(parameters.text) } usedConfigFile = parameters.file @@ -55,5 +50,20 @@ class SetupCommonPipelineEnvironmentTest extends BasePiperTest { assertEquals('develop', nullScript.commonPipelineEnvironment.configuration.general.productiveBranch) assertEquals('my-maven-docker', nullScript.commonPipelineEnvironment.configuration.steps.mavenExecute.dockerImage) } + + @Test + void testWorksAlsoWithYamlFileEnding() throws Exception { + + helper.registerAllowedMethod("fileExists", [String], { String path -> + return path.endsWith('.pipeline/config.yaml') + }) + + stepRule.step.setupCommonPipelineEnvironment(script: nullScript) + + assertEquals('.pipeline/config.yaml', usedConfigFile) + assertNotNull(nullScript.commonPipelineEnvironment.configuration) + assertEquals('develop', nullScript.commonPipelineEnvironment.configuration.general.productiveBranch) + assertEquals('my-maven-docker', nullScript.commonPipelineEnvironment.configuration.steps.mavenExecute.dockerImage) + } } diff --git a/vars/setupCommonPipelineEnvironment.groovy b/vars/setupCommonPipelineEnvironment.groovy index bdf600655..146feb3e4 100644 --- a/vars/setupCommonPipelineEnvironment.groovy +++ b/vars/setupCommonPipelineEnvironment.groovy @@ -60,10 +60,13 @@ void call(Map parameters = [:]) { private loadConfigurationFromFile(script, String configFile) { String defaultYmlConfigFile = '.pipeline/config.yml' + String defaultYamlConfigFile = '.pipeline/config.yaml' if (configFile) { script.commonPipelineEnvironment.configuration = readYaml(file: configFile) } else if (fileExists(defaultYmlConfigFile)) { script.commonPipelineEnvironment.configuration = readYaml(file: defaultYmlConfigFile) + } else if (fileExists(defaultYamlConfigFile)) { + script.commonPipelineEnvironment.configuration = readYaml(file: defaultYamlConfigFile) } } From 136ffa82046c88b2dbd5bc62f7488145cd592d66 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Wed, 31 Jul 2019 12:51:55 +0200 Subject: [PATCH 022/141] Update createDocu.groovy --- documentation/bin/createDocu.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/bin/createDocu.groovy b/documentation/bin/createDocu.groovy index 32bdad344..8420df92c 100644 --- a/documentation/bin/createDocu.groovy +++ b/documentation/bin/createDocu.groovy @@ -489,7 +489,7 @@ class Helper { def params = [] as Set f.eachLine { line -> - if (line ==~ /.*withMandatoryProperty.*/) { + if (line ==~ /.*withMandatoryProperty\(.*/) { def param = (line =~ /.*withMandatoryProperty\('(.*)'/)[0][1] params << param } From e954e3b62988a9f4055e7c82e4dd8375dd06ace2 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 2 Aug 2019 17:05:49 +0200 Subject: [PATCH 023/141] unified behaviour for shell call rule (#794) * Ensure closure gets called when neither returnStdout nor returnStatus are set In this case we do not have a return value, but in case we execute a closure we should execute the closure. With that it is possible to raise an exception from the closure. * [refactoring] unify usage of unify method call * Remove dead code. Coding after uncondition throw exception statement does not get executed. * Ensure script rule behaves the same whan called with string and with map. --- test/groovy/util/JenkinsShellCallRule.groovy | 83 +++++++++----------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/test/groovy/util/JenkinsShellCallRule.groovy b/test/groovy/util/JenkinsShellCallRule.groovy index 6b65baccc..807ef66c8 100644 --- a/test/groovy/util/JenkinsShellCallRule.groovy +++ b/test/groovy/util/JenkinsShellCallRule.groovy @@ -60,6 +60,39 @@ class JenkinsShellCallRule implements TestRule { failingCommands.add(new Command(type, script)) } + def handleShellCall(Map parameters) { + + def unifiedScript = unify(parameters.script) + + shell.add(unifiedScript) + + for (Command failingCommand: failingCommands){ + if(failingCommand.type == Type.REGEX && unifiedScript =~ failingCommand.script) { + throw new Exception("Script execution failed!") + } else if(failingCommand.type == Type.PLAIN && unifiedScript.equals(failingCommand.script)) { + throw new Exception("Script execution failed!") + } + } + + + def result = null + + for(def e : returnValues.entrySet()) { + if(e.key.type == Type.REGEX && unifiedScript =~ e.key.script) { + result = e.value + break + } else if(e.key.type == Type.PLAIN && unifiedScript.equals(e.key.script)) { + result = e.value + break + } + } + if(result instanceof Closure) result = result() + if (!result && parameters.returnStatus) result = 0 + + if(! parameters.returnStdout && ! parameters.returnStatus) return + return result + } + @Override Statement apply(Statement base, Description description) { return statement(base) @@ -71,53 +104,15 @@ class JenkinsShellCallRule implements TestRule { void evaluate() throws Throwable { testInstance.helper.registerAllowedMethod("sh", [String.class], { - command -> - def unifiedScript = unify(command) - - shell.add(unifiedScript) - - for (Command failingCommand: failingCommands){ - if(failingCommand.type == Type.REGEX && unifiedScript =~ failingCommand.script) { - throw new Exception("Script execution failed!") - break - } else if(failingCommand.type == Type.PLAIN && unifiedScript.equals(failingCommand.script)) { - throw new Exception("Script execution failed!") - break - } - } + command -> handleShellCall([ + script: command, + returnStdout: false, + returnStatus: false + ]) }) testInstance.helper.registerAllowedMethod("sh", [Map.class], { - m -> - shell.add(m.script.replaceAll(/\s+/," ").trim()) - - def unifiedScript = unify(m.script) - for (Command failingCommand: failingCommands){ - if(failingCommand.type == Type.REGEX && unifiedScript =~ failingCommand.script) { - throw new Exception("Script execution failed!") - break - } else if(failingCommand.type == Type.PLAIN && unifiedScript.equals(failingCommand.script)) { - throw new Exception("Script execution failed!") - break - } - } - - if (m.returnStdout || m.returnStatus) { - def result = null - - for(def e : returnValues.entrySet()) { - if(e.key.type == Type.REGEX && unifiedScript =~ e.key.script) { - result = e.value - break - } else if(e.key.type == Type.PLAIN && unifiedScript.equals(e.key.script)) { - result = e.value - break - } - } - if(result instanceof Closure) result = result() - if (!result && m.returnStatus) result = 0 - return result - } + m -> handleShellCall(m) }) base.evaluate() From 0c90da66387b9dc3c88e88383362a5624d3bb502 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 6 Aug 2019 13:12:59 +0200 Subject: [PATCH 024/141] Simplify code: failExecution can be replaced by closure raising exception (#795) --- test/groovy/NeoDeployTest.groovy | 4 ++-- test/groovy/util/JenkinsShellCallRule.groovy | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/test/groovy/NeoDeployTest.groovy b/test/groovy/NeoDeployTest.groovy index e64ee6379..44ef4aaff 100644 --- a/test/groovy/NeoDeployTest.groovy +++ b/test/groovy/NeoDeployTest.groovy @@ -519,8 +519,8 @@ class NeoDeployTest extends BasePiperTest { @Test void showLogsOnFailingDeployment() { - thrown.expect(Exception) - shellRule.failExecution(Type.REGEX, '.* deploy .*') + thrown.expect(AbortException) + shellRule.setReturnValue(Type.REGEX, '.* deploy .*', {throw new AbortException()}) stepRule.step.neoDeploy(script: nullScript, source: warArchiveName, diff --git a/test/groovy/util/JenkinsShellCallRule.groovy b/test/groovy/util/JenkinsShellCallRule.groovy index 807ef66c8..9d09e4aec 100644 --- a/test/groovy/util/JenkinsShellCallRule.groovy +++ b/test/groovy/util/JenkinsShellCallRule.groovy @@ -42,7 +42,6 @@ class JenkinsShellCallRule implements TestRule { List shell = [] Map returnValues = [:] - List failingCommands = [] JenkinsShellCallRule(BasePipelineTest testInstance) { this.testInstance = testInstance @@ -56,25 +55,12 @@ class JenkinsShellCallRule implements TestRule { returnValues[new Command(type, script)] = value } - def failExecution(type, script) { - failingCommands.add(new Command(type, script)) - } - def handleShellCall(Map parameters) { def unifiedScript = unify(parameters.script) shell.add(unifiedScript) - for (Command failingCommand: failingCommands){ - if(failingCommand.type == Type.REGEX && unifiedScript =~ failingCommand.script) { - throw new Exception("Script execution failed!") - } else if(failingCommand.type == Type.PLAIN && unifiedScript.equals(failingCommand.script)) { - throw new Exception("Script execution failed!") - } - } - - def result = null for(def e : returnValues.entrySet()) { From 77a8c54084538e9b6137691998f21c78464ca993 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 6 Aug 2019 14:21:57 +0200 Subject: [PATCH 025/141] Remove stdout from unit test (#787) in the majority of the cases there is nobody for reading stdout. --- test/groovy/templates/PiperPipelineStageInitTest.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/test/groovy/templates/PiperPipelineStageInitTest.groovy b/test/groovy/templates/PiperPipelineStageInitTest.groovy index 109c40bb8..63b03c6f4 100644 --- a/test/groovy/templates/PiperPipelineStageInitTest.groovy +++ b/test/groovy/templates/PiperPipelineStageInitTest.groovy @@ -150,7 +150,6 @@ class PiperPipelineStageInitTest extends BasePiperTest { scmInfoTestList.each {scmInfoTest -> jsr.step.piperPipelineStageInit.setScmInfoOnCommonPipelineEnvironment(nullScript, scmInfoTest) - println(scmInfoTest.GIT_URL) assertThat(nullScript.commonPipelineEnvironment.getGitSshUrl(), is(scmInfoTest.expectedSsh)) assertThat(nullScript.commonPipelineEnvironment.getGitHttpsUrl(), is(scmInfoTest.expectedHttp)) assertThat(nullScript.commonPipelineEnvironment.getGithubOrg(), is(scmInfoTest.expectedOrg)) From 62d1e23e6c1d6d5d632a33301fb56d7c6f71246b Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 8 Aug 2019 08:52:21 +0200 Subject: [PATCH 026/141] [fix] custom defaults passed as String instead of String is a List to DefaultValueCache (#817) --- documentation/bin/createDocu.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/bin/createDocu.groovy b/documentation/bin/createDocu.groovy index 8420df92c..f379afdca 100644 --- a/documentation/bin/createDocu.groovy +++ b/documentation/bin/createDocu.groovy @@ -666,7 +666,7 @@ Map stages = Helper.resolveDocuRelevantStages(gse, stepsDir) boolean exceptionCaught = false def stepDescriptors = [:] -DefaultValueCache.prepare(Helper.getDummyScript('noop'), customDefaults) +DefaultValueCache.prepare(Helper.getDummyScript('noop'), [customDefaults: customDefaults]) for (step in steps) { try { stepDescriptors."${step}" = handleStep(step, gse) From 063a1dc3fc4cf6bfeab636dd157e86ef0fac213d Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 7 Aug 2019 13:41:36 +0200 Subject: [PATCH 027/141] Back commonPipelineEnvironment step by shared class Each pipeline step comes with its own instance of a commonPipelineEnvironment. Properties stored on one instance was not shared with the other instances. Now we strip down the commonPipelineEnvironment step and forward basically everything to a shared singleton instance. With that approach all instances of commonPipelineEnvironment shares the same data and can now be really used for information exchange between the steps. Before that change only the commonPipelineEnvironment instance associated with the pipeline script itself could be used for that purpose. --- .../piper/CommonPipelineEnvironment.groovy | 152 ++++++++++++++++++ src/com/sap/piper/DefaultValueCache.groovy | 2 + .../util/JenkinsResetDefaultCacheRule.groovy | 2 + vars/commonPipelineEnvironment.groovy | 146 ++--------------- 4 files changed, 171 insertions(+), 131 deletions(-) create mode 100644 src/com/sap/piper/CommonPipelineEnvironment.groovy diff --git a/src/com/sap/piper/CommonPipelineEnvironment.groovy b/src/com/sap/piper/CommonPipelineEnvironment.groovy new file mode 100644 index 000000000..4cd259660 --- /dev/null +++ b/src/com/sap/piper/CommonPipelineEnvironment.groovy @@ -0,0 +1,152 @@ +package com.sap.piper; + +import com.sap.piper.analytics.InfluxData + +public class CommonPipelineEnvironment { + + private static CommonPipelineEnvironment INSTANCE = new CommonPipelineEnvironment() + + static CommonPipelineEnvironment getInstance() { + INSTANCE + } + + Map defaultConfiguration = [:] + + // The project config + Map configuration = [:] + + private Map valueMap = [:] + + //stores properties for a pipeline which build an artifact and then bundles it into a container + private Map appContainerProperties = [:] + + //stores version of the artifact which is build during pipeline run + def artifactVersion + + //Stores the current buildResult + String buildResult = 'SUCCESS' + + //stores the gitCommitId as well as additional git information for the build during pipeline run + String gitCommitId + String gitCommitMessage + String gitSshUrl + String gitHttpsUrl + String gitBranch + + //GiutHub specific information + String githubOrg + String githubRepo + + String mtarFilePath + + String changeDocumentId + + void setValue(String property, value) { + valueMap[property] = value + } + + def getValue(String property) { + return valueMap.get(property) + } + + def setAppContainerProperty(property, value) { + appContainerProperties[property] = value + } + + def getAppContainerProperty(property) { + return appContainerProperties[property] + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataEntry(key, value) { + InfluxData.addField('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomData() { + return InfluxData.getInstance().getFields().jenkins_custom_data + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataTagsEntry(key, value) { + InfluxData.addTag('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomDataTags() { + return InfluxData.getInstance().getTags().jenkins_custom_data + } + + void setInfluxCustomDataMapEntry(measurement, field, value) { + InfluxData.addField(measurement, field, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMap() { + return InfluxData.getInstance().getFields() + } + + def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { + InfluxData.addTag(measurement, tag, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMapTags() { + return InfluxData.getInstance().getTags() + } + + @Deprecated // not used in library + def setInfluxStepData(key, value) { + InfluxData.addField('step_data', key, value) + } + @Deprecated // not used in library + def getInfluxStepData(key) { + return InfluxData.getInstance().getFields()['step_data'][key] + } + + @Deprecated // not used in library + def setInfluxPipelineData(key, value) { + InfluxData.addField('pipeline_data', key, value) + } + @Deprecated // not used in library + def setPipelineMeasurement(key, value){ + setInfluxPipelineData(key, value) + } + @Deprecated // not used in library + def getPipelineMeasurement(key) { + return InfluxData.getInstance().getFields()['pipeline_data'][key] + } + + def reset() { + appContainerProperties = [:] + configuration = [:] + artifactVersion = null + + gitCommitId = null + gitCommitMessage = null + gitSshUrl = null + gitHttpsUrl = null + gitBranch = null + + githubOrg = null + githubRepo = null + + mtarFilePath = null + valueMap = [:] + + changeDocumentId = null + + InfluxData.reset() + } + + Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { + Map defaults = [:] + if (includeDefaults) { + defaults = DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration([commonPipelineEnvironment: this], stepName), null, defaults) + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration([commonPipelineEnvironment: this], stageName), null, defaults) + } + Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) + config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) + config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) + return config + } +} diff --git a/src/com/sap/piper/DefaultValueCache.groovy b/src/com/sap/piper/DefaultValueCache.groovy index bab5ba47d..99a486f65 100644 --- a/src/com/sap/piper/DefaultValueCache.groovy +++ b/src/com/sap/piper/DefaultValueCache.groovy @@ -6,6 +6,8 @@ import com.sap.piper.MapUtils class DefaultValueCache implements Serializable { private static DefaultValueCache instance + //static CommonPipelineEnvironment commonPipelineEnvironment = new CommonPipelineEnvironment() + private Map defaultValues private DefaultValueCache(Map defaultValues){ diff --git a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy index 680e2fc93..301c29b13 100644 --- a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy +++ b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy @@ -6,6 +6,7 @@ import org.junit.runners.model.Statement import com.lesfurets.jenkins.unit.BasePipelineTest import com.sap.piper.DefaultValueCache +import com.sap.piper.CommonPipelineEnvironment class JenkinsResetDefaultCacheRule implements TestRule { @@ -27,6 +28,7 @@ class JenkinsResetDefaultCacheRule implements TestRule { @Override void evaluate() throws Throwable { DefaultValueCache.reset() + CommonPipelineEnvironment.getInstance().reset() base.evaluate() } } diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index 98d9db24b..49b12f0b5 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -1,144 +1,28 @@ import com.sap.piper.ConfigurationLoader import com.sap.piper.ConfigurationMerger +import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.analytics.InfluxData class commonPipelineEnvironment implements Serializable { - //stores version of the artifact which is build during pipeline run - def artifactVersion + // We forward everything to the singleton instance of + // commonPipelineEnvironment (CPE) on default value cache. + // + // Some background: each step has its own instance of CPE step. + // In case each instance has its own set of properties these instances + // are configured individually. Properties set on one instance cannot be + // retrieved with another instance. Now each instance forwards to one singleton. + // This means: all instances of the CPE shares the same properties/configuration. - //Stores the current buildResult - String buildResult = 'SUCCESS' - - //stores the gitCommitId as well as additional git information for the build during pipeline run - String gitCommitId - String gitCommitMessage - 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 = [:] - - Map configuration = [:] - Map defaultConfiguration = [:] - - String mtarFilePath - private Map valueMap = [:] - - void setValue(String property, value) { - valueMap[property] = value + def methodMissing(String name, def args) { + CommonPipelineEnvironment.getInstance().invokeMethod(name, args) } - def getValue(String property) { - return valueMap.get(property) + def propertyMissing(def name) { + CommonPipelineEnvironment.getInstance()[name] } - String changeDocumentId - - def reset() { - appContainerProperties = [:] - artifactVersion = null - - configuration = [:] - - gitCommitId = null - gitCommitMessage = null - gitSshUrl = null - gitHttpsUrl = null - gitBranch = null - - githubOrg = null - githubRepo = null - - mtarFilePath = null - valueMap = [:] - - changeDocumentId = null - - InfluxData.reset() - } - - def setAppContainerProperty(property, value) { - appContainerProperties[property] = value - } - - def getAppContainerProperty(property) { - return appContainerProperties[property] - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataEntry(key, value) { - InfluxData.addField('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomData() { - return InfluxData.getInstance().getFields().jenkins_custom_data - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataTagsEntry(key, value) { - InfluxData.addTag('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomDataTags() { - return InfluxData.getInstance().getTags().jenkins_custom_data - } - - void setInfluxCustomDataMapEntry(measurement, field, value) { - InfluxData.addField(measurement, field, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMap() { - return InfluxData.getInstance().getFields() - } - - def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { - InfluxData.addTag(measurement, tag, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMapTags() { - return InfluxData.getInstance().getTags() - } - - @Deprecated // not used in library - def setInfluxStepData(key, value) { - InfluxData.addField('step_data', key, value) - } - @Deprecated // not used in library - def getInfluxStepData(key) { - return InfluxData.getInstance().getFields()['step_data'][key] - } - - @Deprecated // not used in library - def setInfluxPipelineData(key, value) { - InfluxData.addField('pipeline_data', key, value) - } - @Deprecated // not used in library - def setPipelineMeasurement(key, value){ - setInfluxPipelineData(key, value) - } - @Deprecated // not used in library - def getPipelineMeasurement(key) { - return InfluxData.getInstance().getFields()['pipeline_data'][key] - } - - Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { - Map defaults = [:] - if (includeDefaults) { - defaults = ConfigurationLoader.defaultGeneralConfiguration() - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration(null, stepName), null, defaults) - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration(null, stageName), null, defaults) - } - Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) - config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) - config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) - return config + def propertyMissing(def name, def value) { + CommonPipelineEnvironment.getInstance()[name] = value } } From 9962060254992e2edd7fcd69756febe1d9609e57 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 8 Aug 2019 12:47:28 +0200 Subject: [PATCH 028/141] ConfigurationLoader, ConfigurationHelper working without script reference --- src/com/sap/piper/ConfigurationHelper.groovy | 38 +++++++++- src/com/sap/piper/ConfigurationLoader.groovy | 76 +++++++++++++++++-- test/groovy/ChecksPublishResultsTest.groovy | 11 ++- .../sap/piper/ConfigurationHelperTest.groovy | 12 ++- .../sap/piper/ConfigurationLoaderTest.groovy | 4 +- 5 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index 155f1fbe0..38b0b80e0 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -31,23 +31,55 @@ class ConfigurationHelper implements Serializable { if(!this.name) throw new IllegalArgumentException('Step has no public name property!') } + /* + * By default this methods does nothing. With this method we are able to ensure that we do not call the + * deprecated methods. Might be usefull during local development. + */ + private static handleDeprecation(script, String methodName) { + if(script != null) { + def msg = "ConfigurationHelper.${methodName} was called with a script reference." + + 'This method is deprecated. Use the same method without the script reference' + if(Boolean.getBoolean('com.sap.piper.failOnScriptReferenceInConfigurationHelper')) + throw new RuntimeException(msg) + if(Boolean.getBoolean('com.sap.piper.emitWarningOnScriptReferenceInConfigurationHelper') && + script instanceof Script) script.echo("[WARNING] ${msg}") + } + } + + ConfigurationHelper collectValidationFailures() { validationResults = validationResults ?: [:] return this } + ConfigurationHelper mixinGeneralConfig(Set filter = null, Map compatibleParameters = [:]){ + mixinGeneralConfig(null, filter, compatibleParameters) + } + @Deprecated + /** Use mixinGeneralConfig without commonPipelineEnvironment*/ ConfigurationHelper mixinGeneralConfig(commonPipelineEnvironment, Set filter = null, Map compatibleParameters = [:]){ - Map generalConfiguration = ConfigurationLoader.generalConfiguration([commonPipelineEnvironment: commonPipelineEnvironment]) + handleDeprecation(commonPipelineEnvironment, 'mixinGeneralConfig') + Map generalConfiguration = ConfigurationLoader.generalConfiguration() return mixin(generalConfiguration, filter, compatibleParameters) } + ConfigurationHelper mixinStageConfig(stageName, Set filter = null, Map compatibleParameters = [:]){ + mixinStageConfig(null, stageName, filter, compatibleParameters) + } + @Deprecated ConfigurationHelper mixinStageConfig(commonPipelineEnvironment, stageName, Set filter = null, Map compatibleParameters = [:]){ - Map stageConfiguration = ConfigurationLoader.stageConfiguration([commonPipelineEnvironment: commonPipelineEnvironment], stageName) + handleDeprecation(commonPipelineEnvironment, 'mixinStageConfig') + Map stageConfiguration = ConfigurationLoader.stageConfiguration(stageName) return mixin(stageConfiguration, filter, compatibleParameters) } + ConfigurationHelper mixinStepConfig(Set filter = null, Map compatibleParameters = [:]){ + mixinStepConfig(null, filter, compatibleParameters) + } + @Deprecated ConfigurationHelper mixinStepConfig(commonPipelineEnvironment, Set filter = null, Map compatibleParameters = [:]){ - Map stepConfiguration = ConfigurationLoader.stepConfiguration([commonPipelineEnvironment: commonPipelineEnvironment], name) + handleDeprecation(commonPipelineEnvironment, 'mixinStepConfig') + Map stepConfiguration = ConfigurationLoader.stepConfiguration(name) return mixin(stepConfiguration, filter, compatibleParameters) } diff --git a/src/com/sap/piper/ConfigurationLoader.groovy b/src/com/sap/piper/ConfigurationLoader.groovy index 27cbdfe18..c6d5200ae 100644 --- a/src/com/sap/piper/ConfigurationLoader.groovy +++ b/src/com/sap/piper/ConfigurationLoader.groovy @@ -1,44 +1,104 @@ package com.sap.piper +// script is present in the signatures in order to keep api compatibility. +// The script referenced is not used inside the method bodies. + @API(deprecated = true) class ConfigurationLoader implements Serializable { + + static Map stepConfiguration(String stepName) { + return stepConfiguration(null, stepName) + } + @Deprecated + /** Use stepConfiguration(stepName) instead */ static Map stepConfiguration(script, String stepName) { - return loadConfiguration(script, 'steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) + return loadConfiguration('steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) } + /* + * By default this methods does nothing. With this method we are able to ensure that we do not call the + * deprecated methods. Might be usefull during local development. + */ + private static handleDeprecation(script, String methodName) { + if(script != null) { + def msg = "ConfigurationLoader.${methodName} was called with a script reference." + + 'This method is deprecated. Use the same method without the script reference' + if(Boolean.getBoolean('com.sap.piper.failOnScriptReferenceInConfigurationLoader')) + throw new RuntimeException(msg) + if(Boolean.getBoolean('com.sap.piper.emitWarningOnScriptReferenceInConfigurationLoader') && + script instanceof Script) script.echo("[WARNING] ${msg}") + } + } + + static Map stageConfiguration(String stageName) { + stageConfiguration(null, stageName) + } + @Deprecated + /** Use stageConfiguration(stageName) instead */ static Map stageConfiguration(script, String stageName) { - return loadConfiguration(script, 'stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) + handleDeprecation(script, 'stageConfiguration') + return loadConfiguration('stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) } + static Map defaultStepConfiguration(String stepName) { + defaultStepConfiguration(null, stepName) + } + @Deprecated + /** Use defaultStepConfiguration(stepName) instead */ static Map defaultStepConfiguration(script, String stepName) { - return loadConfiguration(script, 'steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) + handleDeprecation(script, 'defaultStepConfiguration') + return loadConfiguration('steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) } + static Map defaultStageConfiguration(String stageName) { + defaultStageConfiguration(null, stageName) + } + @Deprecated + /** Use defaultStageConfiguration(stepName) instead */ static Map defaultStageConfiguration(script, String stageName) { - return loadConfiguration(script, 'stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) + handleDeprecation(script, 'defaultStageConfiguration') + return loadConfiguration('stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) } + static Map generalConfiguration(){ + generalConfiguration(null) + } + @Deprecated + /** Use generalConfiguration() instead */ static Map generalConfiguration(script){ + handleDeprecation(script, 'generalConfiguration') try { - return script?.commonPipelineEnvironment?.configuration?.general ?: [:] + return CommonPipelineEnvironment.getInstance()?.configuration?.general ?: [:] } catch (groovy.lang.MissingPropertyException mpe) { return [:] } } + static Map defaultGeneralConfiguration(){ + defaultGeneralConfiguration(null) + } + @Deprecated + /** Use defaultGeneralConfiguration() instead */ static Map defaultGeneralConfiguration(script){ + handleDeprecation(script, 'defaultGeneralConfiguration') return DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] } + static Map postActionConfiguration(String actionName){ + postActionConfiguration(null, actionName) + } + @Deprecated + /** Use postActionConfiguration() instead */ static Map postActionConfiguration(script, String actionName){ - return loadConfiguration(script, 'postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) + handleDeprecation(script, 'postActionConfiguration') + return loadConfiguration('postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) } - private static Map loadConfiguration(script, String type, String entryName, ConfigurationType configType){ + private static Map loadConfiguration(String type, String entryName, ConfigurationType configType){ switch (configType) { case ConfigurationType.CUSTOM_CONFIGURATION: try { - return script?.commonPipelineEnvironment?.configuration?.get(type)?.get(entryName) ?: [:] + return CommonPipelineEnvironment.getInstance()?.configuration?.get(type)?.get(entryName) ?: [:] } catch (groovy.lang.MissingPropertyException mpe) { return [:] } diff --git a/test/groovy/ChecksPublishResultsTest.groovy b/test/groovy/ChecksPublishResultsTest.groovy index 1204edf78..697040f05 100644 --- a/test/groovy/ChecksPublishResultsTest.groovy +++ b/test/groovy/ChecksPublishResultsTest.groovy @@ -3,6 +3,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain + +import com.sap.piper.DefaultValueCache +import com.sap.piper.CommonPipelineEnvironment + import org.junit.Ignore import util.BasePiperTest @@ -154,9 +158,10 @@ class ChecksPublishResultsTest extends BasePiperTest { @Test void testPublishWithChangedStepDefaultSettings() throws Exception { // pmd has been set to active: true in step configuration - stepRule.step.checksPublishResults(script: [commonPipelineEnvironment: [ - configuration: [steps: [checksPublishResults: [pmd: [active: true]]]] - ]]) + CommonPipelineEnvironment.getInstance().configuration = + [steps: [checksPublishResults: [pmd: [active: true]]]] + + stepRule.step.checksPublishResults([script: nullScript]) assertTrue("AnalysisPublisher options not set", publisherStepOptions['AnalysisPublisher'] != null) assertTrue("PmdPublisher options not set", publisherStepOptions['PmdPublisher'] != null) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 49fa437e4..a137d912b 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -101,11 +101,17 @@ class ConfigurationHelperTest { @Test void testConfigurationHelperLoadingStepDefaults() { Set filter = ['property2'] + CommonPipelineEnvironment.getInstance().configuration = [ + general: ['general': 'test', 'oldGeneral': 'test2'], + stages: [testStage:['stage': 'test', 'oldStage': 'test2']], + steps: [mock: [step: 'test', 'oldStep': 'test2']] + ] + Map config = ConfigurationHelper.newInstance(mockScript, [property1: '27']) .loadStepDefaults() - .mixinGeneralConfig([configuration:[general: ['general': 'test', 'oldGeneral': 'test2']]], null, [general2: 'oldGeneral']) - .mixinStageConfig([configuration:[stages:[testStage:['stage': 'test', 'oldStage': 'test2']]]], 'testStage', null, [stage2: 'oldStage']) - .mixinStepConfig([configuration:[steps:[mock: [step: 'test', 'oldStep': 'test2']]]], null, [step2: 'oldStep']) + .mixinGeneralConfig(null, null, [general2: 'oldGeneral']) + .mixinStageConfig(null, 'testStage', null, [stage2: 'oldStage']) + .mixinStepConfig(null, null, [step2: 'oldStep']) .mixin([property1: '41', property2: '28', property3: '29'], filter) .use() // asserts diff --git a/test/groovy/com/sap/piper/ConfigurationLoaderTest.groovy b/test/groovy/com/sap/piper/ConfigurationLoaderTest.groovy index 230ad2028..76c5a9561 100644 --- a/test/groovy/com/sap/piper/ConfigurationLoaderTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationLoaderTest.groovy @@ -17,9 +17,9 @@ class ConfigurationLoaderTest { defaultConfiguration.steps = [executeGradle: [dockerImage: 'gradle:4.0.1-jdk8']] defaultConfiguration.stages = [staticCodeChecks: [pmdExcludes: '*.java']] - def pipelineEnvironment = [configuration: configuration] DefaultValueCache.createInstance(defaultConfiguration) - return [commonPipelineEnvironment: pipelineEnvironment] + CommonPipelineEnvironment.getInstance().configuration = configuration + return [commonPipelineEnvironment: [configuration: configuration]] } @Test From f0a3dd9a393758b57572eeb06d63adceb82a16cd Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 8 Aug 2019 23:50:25 +0200 Subject: [PATCH 029/141] Remove inappropriate package statement and inappr. shebang (#823) --- test/groovy/PiperPublishWarningsTest.groovy | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/groovy/PiperPublishWarningsTest.groovy b/test/groovy/PiperPublishWarningsTest.groovy index 1114c4c6f..e9d43e180 100644 --- a/test/groovy/PiperPublishWarningsTest.groovy +++ b/test/groovy/PiperPublishWarningsTest.groovy @@ -1,6 +1,3 @@ -#!groovy -package steps - import com.sap.piper.JenkinsUtils import static org.hamcrest.Matchers.allOf From f3f4c741bea0513cdad77ebedf44552628f21e6d Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Sun, 11 Aug 2019 22:42:34 +0200 Subject: [PATCH 030/141] sonarExecuteScan: add custom certificate support (#819) * feat(sonar): load TLS certificates * allow verbose property * handle whitespaces * cleanup * disable default verbosity on wget * correct test file name * add test case for custom certificates * import StandardCharsets * change cleanup * correct pull-request provider name * correct pull-request provider name * correct pull-request provider name --- resources/default_pipeline_environment.yml | 2 +- ...est.groovy => SonarExecuteScanTest.groovy} | 19 +++++- vars/sonarExecuteScan.groovy | 58 ++++++++++++++++--- 3 files changed, 70 insertions(+), 9 deletions(-) rename test/groovy/{SonarExecuteTest.groovy => SonarExecuteScanTest.groovy} (91%) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 654340790..f12bdd7fd 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -501,7 +501,7 @@ steps: dockerImage: 'maven:3.5-jdk-8' instance: 'SonarCloud' options: [] - pullRequestProvider: 'github' + pullRequestProvider: 'GitHub' sonarScannerDownloadUrl: 'https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-3.3.0.1492-linux.zip' testsPublishResults: failOnError: false diff --git a/test/groovy/SonarExecuteTest.groovy b/test/groovy/SonarExecuteScanTest.groovy similarity index 91% rename from test/groovy/SonarExecuteTest.groovy rename to test/groovy/SonarExecuteScanTest.groovy index 70c96d0f7..b40ca7e8b 100644 --- a/test/groovy/SonarExecuteTest.groovy +++ b/test/groovy/SonarExecuteScanTest.groovy @@ -146,7 +146,7 @@ class SonarExecuteScanTest extends BasePiperTest { containsString('-Dsonar.pullrequest.key=42'), containsString('-Dsonar.pullrequest.base=master'), containsString('-Dsonar.pullrequest.branch=feature/anything'), - containsString('-Dsonar.pullrequest.provider=github'), + containsString('-Dsonar.pullrequest.provider=GitHub'), containsString('-Dsonar.pullrequest.github.repository=testOrg/testRepo') ))) assertJobStatusSuccess() @@ -234,4 +234,21 @@ class SonarExecuteScanTest extends BasePiperTest { assertThat(jscr.shell, hasItem(containsString('-Dsonar.organization=TestOrg-github'))) assertJobStatusSuccess() } + + @Test + void testWithCustomTlsCertificates() throws Exception { + jsr.step.sonarExecuteScan( + script: nullScript, + juStabUtils: utils, + customTlsCertificateLinks: [ + 'http://url.to/my.cert' + ] + ) + // asserts + assertThat(jscr.shell, allOf( + hasItem(containsString('wget --directory-prefix .certificates/ --no-verbose http://url.to/my.cert')), + hasItem(containsString('keytool -import -noprompt -storepass changeit -keystore .sonar-scanner/jre/lib/security/cacerts -alias \'my.cert\' -file \'.certificates/my.cert\'')) + )) + assertJobStatusSuccess() + } } diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index ecd2fc03c..e590d2097 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -7,6 +7,8 @@ import static com.sap.piper.Prerequisites.checkScript import groovy.transform.Field import groovy.text.SimpleTemplateEngine +import java.nio.charset.StandardCharsets + @Field String STEP_NAME = getClass().getName() @Field Set GENERAL_CONFIG_KEYS = [ @@ -40,8 +42,17 @@ import groovy.text.SimpleTemplateEngine * @possibleValues Jenkins credential id */ 'sonarTokenCredentialsId', + /** + * Print more detailed information into the log. + * @possibleValues `true`, `false` + */ + 'verbose' ] @Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ + /** + * List containing download links of custom TLS certificates. This is required to ensure trusted connections to instances with custom certificates. + */ + 'customTlsCertificateLinks', /** * Pull-Request voting only: * Disables the pull-request decoration with inline comments. @@ -110,14 +121,20 @@ void call(Map parameters = [:]) { def worker = { config -> withSonarQubeEnv(config.instance) { - loadSonarScanner(config) + try{ + loadSonarScanner(config) - if(config.organization) config.options.add("sonar.organization=${config.organization}") - if(config.projectVersion) config.options.add("sonar.projectVersion=${config.projectVersion}") - // prefix options - config.options = config.options.collect { it.startsWith('-D') ? it : "-D${it}" } + loadCertificates(config) - sh "PATH=\$PATH:${env.WORKSPACE}/.sonar-scanner/bin sonar-scanner ${config.options.join(' ')}" + if(config.organization) config.options.add("sonar.organization=${config.organization}") + if(config.projectVersion) config.options.add("sonar.projectVersion=${config.projectVersion}") + // prefix options + config.options = config.options.collect { it.startsWith('-D') ? it : "-D${it}" } + + sh "PATH=\$PATH:${env.WORKSPACE}/.sonar-scanner/bin sonar-scanner ${config.options.join(' ')}" + }finally{ + sh 'rm -rf .sonar-scanner .certificates .scannerwork' + } } } @@ -158,7 +175,7 @@ void call(Map parameters = [:]) { config.options.add("sonar.pullrequest.branch=${env.BRANCH_NAME}") config.options.add("sonar.pullrequest.provider=${config.pullRequestProvider}") switch(config.pullRequestProvider){ - case 'github': + case 'GitHub': config.options.add("sonar.pullrequest.github.repository=${config.githubOrg}/${config.githubRepo}") break default: error "Pull-Request provider '${config.pullRequestProvider}' is not supported!" @@ -191,3 +208,30 @@ private void loadSonarScanner(config){ mv ${foldername} .sonar-scanner """ } + +private void loadCertificates(Map config) { + String certificateFolder = '.certificates/' + List wgetOptions = [ + "--directory-prefix ${certificateFolder}" + ] + List keytoolOptions = [ + '-import', + '-noprompt', + '-storepass changeit', + '-keystore .sonar-scanner/jre/lib/security/cacerts' + ] + if (config.customTlsCertificateLinks){ + if(config.verbose){ + wgetOptions.push('--verbose') + keytoolOptions.push('-v') + }else{ + wgetOptions.push('--no-verbose') + } + config.customTlsCertificateLinks.each { url -> + def filename = new File(url).getName() + filename = URLDecoder.decode(filename, StandardCharsets.UTF_8.name()) + sh "wget ${wgetOptions.join(' ')} ${url}" + sh "keytool ${keytoolOptions.join(' ')} -alias '${filename}' -file '${certificateFolder}${filename}'" + } + } +} From 3af8062c9f995645d883bc4b02a1c941f05c8ed8 Mon Sep 17 00:00:00 2001 From: Florian Wilhelm Date: Tue, 13 Aug 2019 11:54:58 +0200 Subject: [PATCH 031/141] Cleanup (#844) --- src/com/sap/piper/ConfigurationHelper.groovy | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index 155f1fbe0..37319c740 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -1,7 +1,5 @@ package com.sap.piper -import com.cloudbees.groovy.cps.NonCPS - @API class ConfigurationHelper implements Serializable { @@ -51,7 +49,7 @@ class ConfigurationHelper implements Serializable { return mixin(stepConfiguration, filter, compatibleParameters) } - final ConfigurationHelper mixin(Map parameters, Set filter = null, Map compatibleParameters = [:]){ + ConfigurationHelper mixin(Map parameters, Set filter = null, Map compatibleParameters = [:]){ if (parameters.size() > 0 && compatibleParameters.size() > 0) { parameters = ConfigurationMerger.merge(handleCompatibility(compatibleParameters, parameters), null, parameters) } From 023f35c0a8d0176a11d92f864624249404c22697 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 14 Aug 2019 16:44:12 +0200 Subject: [PATCH 032/141] dockerExecuteOnKubernetes - add stashBack configuration (#808) * dockerExecuteOnKubernetes - add stashBack configuration For certain cases it is valuable to only bring back some of the files from an execution inside a container back to the workspace. This is now added. Closes #753 * refactor according to PR review --- .../DockerExecuteOnKubernetesTest.groovy | 37 ++++++++++++++++--- vars/dockerExecuteOnKubernetes.groovy | 34 ++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/test/groovy/DockerExecuteOnKubernetesTest.groovy b/test/groovy/DockerExecuteOnKubernetesTest.groovy index af94602c0..970ef1016 100644 --- a/test/groovy/DockerExecuteOnKubernetesTest.groovy +++ b/test/groovy/DockerExecuteOnKubernetesTest.groovy @@ -58,7 +58,7 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { def pullImageMap = [:] def namespace def securityContext - Map stashMap + List stashList = [] @Before void init() { @@ -96,7 +96,7 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { body() }) helper.registerAllowedMethod('stash', [Map.class], {m -> - stashMap = m + stashList.add(m) }) } @@ -389,7 +389,7 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { @Test void testDockerExecuteOnKubernetesCustomJnlpViaEnv() { - nullScript.configuration = [ + nullScript.commonPipelineEnvironment.configuration = [ general: [jenkinsKubernetes: [jnlpAgent: 'config/jnlp:latest']] ] binding.variables.env.JENKINS_JNLP_IMAGE = 'env/jnlp:latest' @@ -413,10 +413,10 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { @Test void testDockerExecuteOnKubernetesCustomJnlpViaConfig() { - nullScript.configuration = [ + nullScript.commonPipelineEnvironment.configuration = [ general: [jenkinsKubernetes: [jnlpAgent: 'config/jnlp:latest']] ] - binding.variables.env.JENKINS_JNLP_IMAGE = 'config/jnlp:latest' + //binding.variables.env.JENKINS_JNLP_IMAGE = 'config/jnlp:latest' stepRule.step.dockerExecuteOnKubernetes( script: nullScript, juStabUtils: utils, @@ -434,6 +434,33 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { )) } + @Test + void tastStashIncludesAndExcludes() { + nullScript.commonPipelineEnvironment.configuration = [ + steps: [ + dockerExecuteOnKubernetes: [ + stashExcludes: [ + workspace: 'workspace/exclude.test', + stashBack: 'container/exclude.test' + ], + stashIncludes: [ + workspace: 'workspace/include.test', + stashBack: 'container/include.test' + ] + ] + ] + ] + stepRule.step.dockerExecuteOnKubernetes( + script: nullScript, + juStabUtils: utils, + dockerImage: 'maven:3.5-jdk-8-alpine', + ) { + bodyExecuted = true + } + assertThat(stashList[0], allOf(hasEntry('includes','workspace/include.test'), hasEntry('excludes','workspace/exclude.test'))) + assertThat(stashList[1], allOf(hasEntry('includes','container/include.test'), hasEntry('excludes','container/exclude.test'))) + } + private container(options, body) { containerName = options.name diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index c6ef682a7..b1a8db3ce 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -90,11 +90,21 @@ import hudson.AbortException */ 'stashContent', /** + * In the Kubernetes case the workspace is only available to the respective Jenkins slave but not to the containers running inside the pod.
+ * This configuration defines exclude pattern for stashing from Jenkins workspace to working directory in container and back. + * Following excludes can be set: * + * * `workspace`: Pattern for stashing towards container + * * `stashBack`: Pattern for bringing data from container back to Jenkins workspace. If not set: defaults to setting for `workspace`. */ 'stashExcludes', /** + * In the Kubernetes case the workspace is only available to the respective Jenkins slave but not to the containers running inside the pod.
+ * This configuration defines include pattern for stashing from Jenkins workspace to working directory in container and back. + * Following includes can be set: * + * * `workspace`: Pattern for stashing towards container + * * `stashBack`: Pattern for bringing data from container back to Jenkins workspace. If not set: defaults to setting for `workspace`. */ 'stashIncludes' ]) @@ -202,7 +212,7 @@ void executeOnPod(Map config, utils, Closure body) { utils.unstashAll(stashContent) body() } finally { - stashWorkspace(config, 'container', true) + stashWorkspace(config, 'container', true, true) } } } else { @@ -234,7 +244,7 @@ private String generatePodSpec(Map config) { } -private String stashWorkspace(config, prefix, boolean chown = false) { +private String stashWorkspace(config, prefix, boolean chown = false, boolean stashBack = false) { def stashName = "${prefix}-${config.uniqueId}" try { if (chown) { @@ -244,13 +254,25 @@ private String stashWorkspace(config, prefix, boolean chown = false) { sh """#!${config.containerShell?:'/bin/sh'} chown -R ${runAsUser}:${fsGroup} .""" } + + def includes, excludes + + if(stashBack) { + includes = config.stashIncludes.stashBack ?: config.stashIncludes.workspace + excludes = config.stashExcludes.stashBack ?: config.stashExcludes.workspace + } else { + includes = config.stashIncludes.workspace + excludes = config.stashExcludes.workspace + } + stash( name: stashName, - includes: config.stashIncludes.workspace, - excludes: config.stashExcludes.workspace, - //inactive due to negative side-effects, we may require a dedicated git stash to be used - //useDefaultExcludes: false + includes: includes, + excludes: excludes ) + //inactive due to negative side-effects, we may require a dedicated git stash to be used + //useDefaultExcludes: false) + return stashName } catch (AbortException | IOException e) { echo "${e.getMessage()}" From fa3b6b68dbd3bd71bdbcd8df5bd596693893352b Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Thu, 15 Aug 2019 15:26:08 +0200 Subject: [PATCH 033/141] githubPublishRelease - add templating capabilities (#849) add templating capabilities for the header in the release information --- test/groovy/GithubPublishReleaseTest.groovy | 14 ++++++++++++++ vars/githubPublishRelease.groovy | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/test/groovy/GithubPublishReleaseTest.groovy b/test/groovy/GithubPublishReleaseTest.groovy index c1b0f1785..30d6912ed 100644 --- a/test/groovy/GithubPublishReleaseTest.groovy +++ b/test/groovy/GithubPublishReleaseTest.groovy @@ -214,4 +214,18 @@ class GithubPublishReleaseTest extends BasePiperTest { assertJobStatusSuccess() } + @Test + void testTemplating() { + nullScript.commonPipelineEnvironment.setArtifactVersion('1.2.3') + stepRule.step.githubPublishRelease( + script: nullScript, + githubOrg: 'TestOrg', + githubRepo: 'TestRepo', + githubTokenCredentialsId: 'TestCredentials', + releaseBodyHeader: 'This is my release header with version: ${commonPipelineEnvironment.getArtifactVersion()} for githubOrg: ${config.githubOrg}' + ) + + assertThat('the list of closed PR is not present', data.body, containsString('This is my release header with version: 1.2.3 for githubOrg: TestOrg
')) + } + } diff --git a/vars/githubPublishRelease.groovy b/vars/githubPublishRelease.groovy index 2b1c5bec8..1616f3d9a 100644 --- a/vars/githubPublishRelease.groovy +++ b/vars/githubPublishRelease.groovy @@ -6,6 +6,7 @@ import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() @@ -41,7 +42,10 @@ import groovy.transform.Field 'githubOrg', /** Allows to overwrite the GitHub repository.*/ 'githubRepo', - /** Allows to specify the content which will appear for the release.*/ + /** Allows to specify the content which will appear for the release. + * It is possible to define it as Groovy template as well in order to bring in dynamic information. + * Following information can be used: everything contained in `config` as well as information from `commonPipelineEnvironment`. + */ 'releaseBodyHeader', /** Defines the version number which will be written as tag as well as release name.*/ 'version' @@ -86,7 +90,18 @@ void call(Map parameters = [:]) { new Utils().pushToSWA([step: STEP_NAME], config) withCredentials([string(credentialsId: config.githubTokenCredentialsId, variable: 'TOKEN')]) { - def releaseBody = config.releaseBodyHeader?"${config.releaseBodyHeader}
":'' + + def releaseBodyHeader = '' + if (config.releaseBodyHeader) { + releaseBodyHeader = GStringTemplateEngine.newInstance() + .createTemplate(config.releaseBodyHeader) + .make([ + config: config, + commonPipelineEnvironment: script.commonPipelineEnvironment + ]).toString() + releaseBodyHeader += '
' + } + def releaseBody = releaseBodyHeader def content = getLastRelease(config, TOKEN) if (config.addClosedIssues) releaseBody += addClosedIssue(config, TOKEN, content.published_at) From f69eac6f5f45d6405b636cfd0669bb510e7d0f0e Mon Sep 17 00:00:00 2001 From: Florian Geckeler <43751896+fgeckeler@users.noreply.github.com> Date: Fri, 16 Aug 2019 17:05:18 +0200 Subject: [PATCH 034/141] Pass configured env vars to docker execution in existing container (#851) --- test/groovy/DockerExecuteTest.groovy | 6 ++++++ vars/dockerExecute.groovy | 12 +++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/test/groovy/DockerExecuteTest.groovy b/test/groovy/DockerExecuteTest.groovy index 5c140a8eb..ce8a3e236 100644 --- a/test/groovy/DockerExecuteTest.groovy +++ b/test/groovy/DockerExecuteTest.groovy @@ -48,10 +48,15 @@ class DockerExecuteTest extends BasePiperTest { @Test void testExecuteInsideContainerOfExistingPod() throws Exception { + List usedDockerEnvVars helper.registerAllowedMethod('container', [String.class, Closure.class], { String container, Closure body -> containerName = container body() }) + helper.registerAllowedMethod('withEnv',[List.class, Closure.class], { List envVars, Closure body -> + usedDockerEnvVars = envVars + body() + }) binding.setVariable('env', [POD_NAME: 'testpod', ON_K8S: 'true']) ContainerMap.instance.setMap(['testpod': ['maven:3.5-jdk-8-alpine': 'mavenexec']]) stepRule.step.dockerExecute(script: nullScript, @@ -61,6 +66,7 @@ class DockerExecuteTest extends BasePiperTest { } assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Container')) assertEquals('mavenexec', containerName) + assertEquals(usedDockerEnvVars[0].toString(), "http_proxy=http://proxy:8000") assertTrue(bodyExecuted) } diff --git a/vars/dockerExecute.groovy b/vars/dockerExecute.groovy index c86c3a778..a81c40ee4 100644 --- a/vars/dockerExecute.groovy +++ b/vars/dockerExecute.groovy @@ -133,11 +133,17 @@ void call(Map parameters = [:], body) { ], config) if (isKubernetes() && config.dockerImage) { + List dockerEnvVars = [] + config.dockerEnvVars?.each { key, value -> + dockerEnvVars << "$key=$value" + } if (env.POD_NAME && isContainerDefined(config)) { container(getContainerDefined(config)) { - echo "[INFO][${STEP_NAME}] Executing inside a Kubernetes Container." - body() - sh "chown -R 1000:1000 ." + withEnv(dockerEnvVars) { + echo "[INFO][${STEP_NAME}] Executing inside a Kubernetes Container." + body() + sh "chown -R 1000:1000 ." + } } } else { if (!config.sidecarImage) { From 8c966e41c435d08638bea6277a47643d8ec682f6 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 20 Aug 2019 09:08:42 +0200 Subject: [PATCH 035/141] more precise param handover (gitUrl) in piperPipelineStageInit (#848) Before: complete scmInfo was handed over via method signature. After: Only the relevant part (GIT_URL from scmInfo) is handed over. All the other properties from scmInfo are not used in the method body. With this appraoch it is more obvious what is used inside the method. --- test/groovy/templates/PiperPipelineStageInitTest.groovy | 2 +- vars/piperPipelineStageInit.groovy | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/groovy/templates/PiperPipelineStageInitTest.groovy b/test/groovy/templates/PiperPipelineStageInitTest.groovy index 63b03c6f4..92580540d 100644 --- a/test/groovy/templates/PiperPipelineStageInitTest.groovy +++ b/test/groovy/templates/PiperPipelineStageInitTest.groovy @@ -149,7 +149,7 @@ class PiperPipelineStageInitTest extends BasePiperTest { ] scmInfoTestList.each {scmInfoTest -> - jsr.step.piperPipelineStageInit.setScmInfoOnCommonPipelineEnvironment(nullScript, scmInfoTest) + jsr.step.piperPipelineStageInit.setGitUrlsOnCommonPipelineEnvironment(nullScript, scmInfoTest.GIT_URL) assertThat(nullScript.commonPipelineEnvironment.getGitSshUrl(), is(scmInfoTest.expectedSsh)) assertThat(nullScript.commonPipelineEnvironment.getGitHttpsUrl(), is(scmInfoTest.expectedHttp)) assertThat(nullScript.commonPipelineEnvironment.getGithubOrg(), is(scmInfoTest.expectedOrg)) diff --git a/vars/piperPipelineStageInit.groovy b/vars/piperPipelineStageInit.groovy index 60f5b226a..2a682ec73 100644 --- a/vars/piperPipelineStageInit.groovy +++ b/vars/piperPipelineStageInit.groovy @@ -63,7 +63,7 @@ void call(Map parameters = [:]) { //perform stashing based on libray resource piper-stash-settings.yml if not configured otherwise initStashConfiguration(script, config) - setScmInfoOnCommonPipelineEnvironment(script, scmInfo) + setGitUrlsOnCommonPipelineEnvironment(script, scmInfo.GIT_URL) script.commonPipelineEnvironment.setGitCommitId(scmInfo.GIT_COMMIT) if (config.verbose) { @@ -131,9 +131,7 @@ private void initStashConfiguration (script, config) { script.commonPipelineEnvironment.configuration.stageStashes = stashConfiguration } -private void setScmInfoOnCommonPipelineEnvironment(script, scmInfo) { - - def gitUrl = scmInfo.GIT_URL +private void setGitUrlsOnCommonPipelineEnvironment(script, String gitUrl) { def gitPath = '' if (gitUrl.startsWith('http')) { From d2d42328831bb5e96158dd4dc5a31ed8157898c2 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 21 Aug 2019 13:10:54 +0200 Subject: [PATCH 036/141] Remove useless utils from signature inside neoDeploy (#853) --- vars/neoDeploy.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vars/neoDeploy.groovy b/vars/neoDeploy.groovy index fa0090a71..d9b6c74bb 100644 --- a/vars/neoDeploy.groovy +++ b/vars/neoDeploy.groovy @@ -208,14 +208,14 @@ void call(parameters = [:]) { ) lock("$STEP_NAME :${neoCommandHelper.resourceLock()}") { - deploy(script, utils, configuration, neoCommandHelper, configuration.dockerImage, deployMode) + deploy(script, configuration, neoCommandHelper, configuration.dockerImage, deployMode) } } } } } -private deploy(script, utils, Map configuration, NeoCommandHelper neoCommandHelper, dockerImage, DeployMode deployMode) { +private deploy(script, Map configuration, NeoCommandHelper neoCommandHelper, dockerImage, DeployMode deployMode) { String logFolder = 'logs/neo' From 8cb5779f5d13aca9120a8ed777a684614942b9f1 Mon Sep 17 00:00:00 2001 From: Hans Schulz Date: Wed, 21 Aug 2019 15:04:20 +0200 Subject: [PATCH 037/141] Fix imagePullPolicy always being IfNotPresent when executing single container (#834) * Update dockerExecuteOnKubernetes.groovy * Update dockerExecuteOnKubernetes.groovy --- vars/dockerExecuteOnKubernetes.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index b1a8db3ce..876d478ed 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -156,8 +156,8 @@ void call(Map parameters = [:], body) { if (!parameters.containerMap) { configHelper.withMandatoryProperty('dockerImage') config.containerName = 'container-exec' - config.containerMap = ["${config.get('dockerImage')}": config.containerName] - config.containerCommands = config.containerCommand ? ["${config.get('dockerImage')}": config.containerCommand] : null + config.containerMap = [(config.get('dockerImage')): config.containerName] + config.containerCommands = config.containerCommand ? [(config.get('dockerImage')): config.containerCommand] : null } executeOnPod(config, utils, body) } From b3764f4c1b56f27b77ef845739462ed49bb0fd3d Mon Sep 17 00:00:00 2001 From: Christoph Szymanski Date: Fri, 23 Aug 2019 13:33:38 +0200 Subject: [PATCH 038/141] Link the SAP Cloud SDK (#861) SAP Cloud SDK implements a more comprehensive CAP pipeline. Co-Authored-By: Florian Wilhelm --- documentation/docs/scenarios/CAP_Scenario.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/scenarios/CAP_Scenario.md b/documentation/docs/scenarios/CAP_Scenario.md index 2975ee2f5..22cd3936f 100644 --- a/documentation/docs/scenarios/CAP_Scenario.md +++ b/documentation/docs/scenarios/CAP_Scenario.md @@ -1,6 +1,6 @@ # Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model -Set up a basic continuous delivery process for developing applications according to the SAP Cloud Application Programming Model. +Set up a basic continuous delivery process for developing applications according to the SAP Cloud Application Programming Model. If you're building extensions of SAP solutions such as SAP S/4HANA, consider using [SAP Cloud SDK](https://developers.sap.com/topics/cloud-sdk.html) and [SAP Cloud SDK Pipeline](https://github.com/SAP/cloud-s4-sdk-pipeline) which provides an out-of-the-box continuous delivery pipeline based on project "Piper". ## Prerequisites From 211827ac937ff469ac50a658d3893a5041b4584f Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 30 Aug 2019 09:03:08 +0200 Subject: [PATCH 039/141] Remove template docu page for piperPipelinStagePost (#863) There are at the moment no stage docu pages. I guess this has been added too early by mistake. Maybe we should remove this for now since it triggers a warning during docu generation. --- documentation/docs/steps/piperPipelineStagePost.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 documentation/docs/steps/piperPipelineStagePost.md diff --git a/documentation/docs/steps/piperPipelineStagePost.md b/documentation/docs/steps/piperPipelineStagePost.md deleted file mode 100644 index 3d8f3171a..000000000 --- a/documentation/docs/steps/piperPipelineStagePost.md +++ /dev/null @@ -1,9 +0,0 @@ -# ${docGenStepName} - -## ${docGenDescription} - -## ${docGenParameters} - -## ${docGenConfiguration} - -## ${docJenkinsPluginDependencies} From 41dcebb3a59576b642a7338fb0b92b6cf984eadf Mon Sep 17 00:00:00 2001 From: TheFonz2017 <31238517+TheFonz2017@users.noreply.github.com> Date: Fri, 6 Sep 2019 10:20:35 +0200 Subject: [PATCH 040/141] Variable Substitution in YAML Files (#852) * Changes: - New YamlSubstituteVariables step to substitute variables in YAML files with values from another YAML - New Tests, that check the different substitution patterns. - Added test resources, including various manifest and variables files. - Improved usage of JenkinsLoggingRule - Improved JenkinsReadYamlRule to properly reflect the mocked library's behaviour. - Added a new JenkinsWriteYamlRule. * Changes: - added a Logger that checks a config.verbose flag before it logs debug messages. - changed error handling to rethrow Yaml parsing exception in case of wrongly-formatted Yaml files. - changed JenkinsWriteYamlRule to capture Yaml file details of every invocation of writeYaml. This allows sanity checks at end of tests, even if there were multiple invocations. - adjusted tests. * Changes: - Removed javadoc-code blocks from API documentation since they are not supported. - Removed skipDeletion boolean. - Added a new deleteFile script which deletes a file if present. - Added a new JenkinsDeleteFileRule to mock deleteFile script and optionally skip deletion for tests. - Adjusted yamlSubstituteVariables script. - Adjusted tests to include new JenkinsDeleteFileRule. - Changed code that deletes an already existing output file to produce better logs. * Changes: - Turned yamlSubstituteVariables into a script that works purely based on Yaml data (not files). - Added a new cfManifestSubstituteVariables that uses yamlSubstituteVariables under the hood but works based on files. - Adjusted tests, and added new ones. * Adjusted documentation and a few log statements. * Changed documentation to no longer include javadoc code statements. * Made mocking of deletion of a file a default. Adjusted tests. * Changed signature of yamlSubstituteVariables' call method to return void. * Changes: - Fixed naming issues in deleteFile. - Renamed Logger to DebugHelper. - Fixed some documentation. * Changed implementation of deleteFile not to use java.io.File - which is evil when using it for file operations. * PROPERLY Changed implementation of deleteFile not to use java.io.File - which is evil when using it for file operations. * Changes: - Added tests for deleteFile script - Changed JenkinsFileExistsRule to also keep track of which files have been queried for existence. * Changes: - Removed java.io.File usage from cfManifestSubstituteVariables and using fileExists instead now. - Adjusted tests. * Wrapped file path inside ticks to allow spaces in file path when calling deleteFile. * Removed null checks of mandatory parameters, and resorted to ConfigurationHelper.withMandatoryProperty * Fixed a NullPointer due to weird Jenkins / Groovy behaviour. * Changes: - Turned yamlSubstituteVariables step into a utils class. - Added tests - Adjusted cfManifestSubstituteVariables to use utils class instead of step. - Adjusted tests - Adjusted APIs of DebugHelper. * Re-introduced log statement that shows what variables are being replaced and with what. * Changing API of YamlUtils to take the script and config as input. * Test * Test * Test * Test * Test * Fixing issue. * Fixing issue. * Changes: - Refactored DebugHelper and YamlUtils to make usage nicer and rely on dependency injection. - Removed Field for DebugHelper and turned it into local variable. - Adjusted classes using the above. - Adjusted tests where necessary. * Added link to CF standards to YamlUtils also. * Add docu for step cfManifestSubstituteVariables.md * Added documentation. * Added missing script parameter to documentation. Some steps document it, some don't. Right now you need it, so we document it. * Fixed some layouting and typos * Beautified exception listing. * Removed trailing whitespaces to make code climate checks pass. * Trying to get documentation generated, with all the exceptions to markup one should not use. * cosmetics. * cosmetics, part 2 * Code climate changes... * Inlined deleteFile step. * Added two more tests to properly check file deletion and output handling. * Changes: - adjusted API to take a list of variables files, as does 'cf push --vars-file' - adjusted API to allow for an optional list of variable key-value-maps as does 'cf push --vars' - reproduced conflict resolution and overriding behavior of variables files and vars lists - adjusted tests and documentation * Added missing paramter to doc comment. * Re-checked docs for missing paramters or params that have no counterpart in the method signature. * Adjusted documentation. * Removed absolute path usage from documentation. * corrected documentation. * Changed javadoc comment to plain comment. * Turned all comments to plain comments. --- .../steps/cfManifestSubstituteVariables.md | 73 +++ documentation/mkdocs.yml | 1 + .../variablesubstitution/DebugHelper.groovy | 53 ++ .../ExecutionContext.groovy | 18 + .../variablesubstitution/YamlUtils.groovy | 225 +++++++ .../CfManifestSubstituteVariablesTest.groovy | 600 ++++++++++++++++++ .../variablesubstitution/YamlUtilsTest.groovy | 271 ++++++++ test/groovy/util/JenkinsFileExistsRule.groovy | 21 +- test/groovy/util/JenkinsLoggingRule.groovy | 3 +- test/groovy/util/JenkinsReadYamlRule.groovy | 28 +- test/groovy/util/JenkinsWriteYamlRule.groovy | 59 ++ .../datatypes_manifest-variables.yml | 19 + .../datatypes_manifest.yml | 27 + .../variableSubstitution/invalid_manifest.yml | 2 + .../manifest-variables-conflicting.yml | 4 + .../manifest-variables.yml | 4 + .../variableSubstitution/manifest.yml | 21 + .../variableSubstitution/multi_manifest.yml | 43 ++ .../variableSubstitution/novars_manifest.yml | 21 + vars/cfManifestSubstituteVariables.groovy | 264 ++++++++ 20 files changed, 1753 insertions(+), 4 deletions(-) create mode 100644 documentation/docs/steps/cfManifestSubstituteVariables.md create mode 100644 src/com/sap/piper/variablesubstitution/DebugHelper.groovy create mode 100644 src/com/sap/piper/variablesubstitution/ExecutionContext.groovy create mode 100644 src/com/sap/piper/variablesubstitution/YamlUtils.groovy create mode 100644 test/groovy/CfManifestSubstituteVariablesTest.groovy create mode 100644 test/groovy/com/sap/piper/variablesubstitution/YamlUtilsTest.groovy create mode 100644 test/groovy/util/JenkinsWriteYamlRule.groovy create mode 100644 test/resources/variableSubstitution/datatypes_manifest-variables.yml create mode 100644 test/resources/variableSubstitution/datatypes_manifest.yml create mode 100644 test/resources/variableSubstitution/invalid_manifest.yml create mode 100644 test/resources/variableSubstitution/manifest-variables-conflicting.yml create mode 100644 test/resources/variableSubstitution/manifest-variables.yml create mode 100644 test/resources/variableSubstitution/manifest.yml create mode 100644 test/resources/variableSubstitution/multi_manifest.yml create mode 100644 test/resources/variableSubstitution/novars_manifest.yml create mode 100644 vars/cfManifestSubstituteVariables.groovy diff --git a/documentation/docs/steps/cfManifestSubstituteVariables.md b/documentation/docs/steps/cfManifestSubstituteVariables.md new file mode 100644 index 000000000..73d3cee1e --- /dev/null +++ b/documentation/docs/steps/cfManifestSubstituteVariables.md @@ -0,0 +1,73 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} + +## Side effects + +Unless configured otherwise, this step will *replace* the input `manifest.yml` with a version that has all variable references replaced. This alters the source tree in your Jenkins workspace. +If you prefer to generate a separate output file, use the step's `outputManifestFile` parameter. Keep in mind, however, that your Cloud Foundry deployment step should then also reference this output file - otherwise CF deployment will fail with unresolved variable reference errors. + +## Exceptions + +* `org.yaml.snakeyaml.scanner.ScannerException` - in case any of the loaded input files contains malformed Yaml and cannot be parsed. + +* `hudson.AbortException` - in case of internal errors and when not all variables could be replaced due to missing replacement values. + +## Example + +Usage of pipeline step: + +```groovy +cfManifestSubstituteVariables ( + script: this, + manifestFile: "path/to/manifest.yml", //optional, default: manifest.yml + manifestVariablesFiles: ["path/to/manifest-variables.yml"] //optional, default: ['manifest-variables.yml'] + manifestVariables: [[key : value], [key : value]] //optional, default: [] +) +``` + +For example, you can refer to the parameters using relative paths (similar to `cf push --vars-file`): + +```groovy +cfManifestSubstituteVariables ( + script: this, + manifestFile: "manifest.yml", + manifestVariablesFiles: ["manifest-variables.yml"] +) +``` + +Furthermore, you can also specify variables and their values directly (similar to `cf push --var`): + +```groovy +cfManifestSubstituteVariables ( + script: this, + manifestFile: "manifest.yml", + manifestVariablesFiles: ["manifest-variables.yml"], + manifestVariables: [[key1 : value1], [key2 : value2]] +) +``` + +If you are using the Cloud Foundry [Create-Service-Push](https://github.com/dawu415/CF-CLI-Create-Service-Push-Plugin) CLI plugin you will most likely also have a `services-manifest.yml` file. +Also in this file you can specify variable references, that can be resolved from the same variables file, e.g. like this: + +```groovy +// resolve variables in manifest.yml +cfManifestSubstituteVariables ( + script: this, + manifestFile: "manifest.yml", + manifestVariablesFiles: ["manifest-variables.yml"] +) + +// resolve variables in services-manifest.yml from same file. +cfManifestSubstituteVariables ( + script: this, + manifestFile: "services-manifest.yml", + manifestVariablesFiles: ["manifest-variables.yml"] +) +``` diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 8177dca37..904aedcce 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -9,6 +9,7 @@ nav: - batsExecuteTests: steps/batsExecuteTests.md - checkChangeInDevelopment: steps/checkChangeInDevelopment.md - checksPublishResults: steps/checksPublishResults.md + - cfManifestSubstituteVariables: steps/cfManifestSubstituteVariables.md - cloudFoundryDeploy: steps/cloudFoundryDeploy.md - commonPipelineEnvironment: steps/commonPipelineEnvironment.md - containerExecuteStructureTests: steps/containerExecuteStructureTests.md diff --git a/src/com/sap/piper/variablesubstitution/DebugHelper.groovy b/src/com/sap/piper/variablesubstitution/DebugHelper.groovy new file mode 100644 index 000000000..329b09d23 --- /dev/null +++ b/src/com/sap/piper/variablesubstitution/DebugHelper.groovy @@ -0,0 +1,53 @@ +package com.sap.piper.variablesubstitution + +/** + * Very simple debug helper. Declared as a Field + * and passed the configuration with a call to `setConfig(Map config)` + * in the body of a `call(...)` block, once + * the configuration is available. + * + * If `config.verbose` is set to `true` a message + * issued with `debug(String)` will be logged. Otherwise it will silently be omitted. + */ +class DebugHelper { + /** + * The script which will be used to echo debug messages. + */ + private Script script + /** + * The configuration which will be scanned for a `verbose` flag. + * Only if this is true, will debug messages be written. + */ + private Map config + + /** + * Creates a new instance using the given script to issue `echo` commands. + * The given config's `verbose` flag will decide if a message will be logged or not. + * @param script - the script whose `echo` command will be used. + * @param config - the configuration whose `verbose` flag is inspected before logging debug statements. + */ + DebugHelper(Script script, Map config) { + if(!script) { + throw new IllegalArgumentException("[DebugHelper] Script parameter must not be null.") + } + + if(!config) { + throw new IllegalArgumentException("[DebugHelper] Config map parameter must not be null.") + } + + this.script = script + this.config = config + } + + /** + * Logs a debug message if a configuration + * indicates that the `verbose` flag + * is set to `true` + * @param message - the message to log. + */ + void debug(String message) { + if(config.verbose) { + script.echo message + } + } +} diff --git a/src/com/sap/piper/variablesubstitution/ExecutionContext.groovy b/src/com/sap/piper/variablesubstitution/ExecutionContext.groovy new file mode 100644 index 000000000..90dc109f2 --- /dev/null +++ b/src/com/sap/piper/variablesubstitution/ExecutionContext.groovy @@ -0,0 +1,18 @@ +package com.sap.piper.variablesubstitution + +/** + * A simple class to capture context information + * of the execution of yamlSubstituteVariables. + */ +class ExecutionContext { + /** + * Property indicating if the execution + * of yamlSubstituteVariables actually + * substituted any variables at all. + * + * Does NOT indicate that ALL variables were + * actually replaced. If set to true, if just indicates + * that some or all variables have been replaced. + */ + Boolean variablesReplaced = false +} diff --git a/src/com/sap/piper/variablesubstitution/YamlUtils.groovy b/src/com/sap/piper/variablesubstitution/YamlUtils.groovy new file mode 100644 index 000000000..3f3e84cfa --- /dev/null +++ b/src/com/sap/piper/variablesubstitution/YamlUtils.groovy @@ -0,0 +1,225 @@ +package com.sap.piper.variablesubstitution + +import hudson.AbortException + +/** + * A utility class for Yaml data. + * Deals with the substitution of variables within Yaml objects. + */ +class YamlUtils implements Serializable { + + private final DebugHelper logger + private final Script script + + /** + * Creates a new utils instance for the given script. + * @param script - the script which will be used to call pipeline steps. + * @param logger - an optional debug helper to print debug messages. + */ + YamlUtils(Script script, DebugHelper logger = null) { + if(!script) { + throw new IllegalArgumentException("[YamlUtils] Script must not be null.") + } + this.script = script + this.logger = logger + } + + /** + * Substitutes variables references in a given input Yaml object with values that are read from the + * passed variables Yaml object. Variables may be of primitive or complex types. + * The format of variable references follows [Cloud Foundry standards](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#variable-substitution) + * + * @param inputYaml - the input Yaml data as `Object`. Can be either of type `Map` or `List`. + * @param variablesYaml - the variables Yaml data as `Object`. Can be either of type `Map` or `List` and should + * contain variables names and values to replace variable references contained in `inputYaml`. + * @param context - an `ExecutionContext` that can be used to query whether the script actually replaced any variables. + * @return the YAML object graph of substituted data. + */ + Object substituteVariables(Object inputYaml, Object variablesYaml, ExecutionContext context = null) { + if (!inputYaml) { + throw new IllegalArgumentException("[YamlUtils] Input Yaml data must not be null or empty.") + } + + if (!variablesYaml) { + throw new IllegalArgumentException("[YamlUtils] Variables Yaml data must not be null or empty.") + } + + return substitute(inputYaml, variablesYaml, context) + } + + /** + * Recursively substitutes all variables inside the object tree of the manifest YAML. + * @param manifestNode - the manifest YAML to replace variables in. + * @param variablesData - the variables values. + * @param context - an execution context that can be used to query if any variables were replaced. + * @return a YAML object graph which has all variables replaced. + */ + private Object substitute(Object manifestNode, Object variablesData, ExecutionContext context) { + Map variableSubstitutes = getVariableSubstitutes(variablesData) + + if (containsVariableReferences(manifestNode)) { + + Object complexResult = null + String stringNode = manifestNode as String + Map referencedVariables = getReferencedVariables(stringNode) + referencedVariables.each { referencedVariable -> + String referenceToReplace = referencedVariable.getKey() + String referenceName = referencedVariable.getValue() + Object substitute = variableSubstitutes.get(referenceName) + + if (null == substitute) { + logger?.debug("[YamlUtils] WARNING - Found variable reference ${referenceToReplace} in input Yaml but no variable value to replace it with Leaving it unresolved. Check your variables Yaml data and make sure the variable is properly declared.") + return manifestNode + } + + script.echo "[YamlUtils] Replacing: ${referenceToReplace} with ${substitute}" + + if(isSingleVariableReference(stringNode)) { + logger?.debug("[YamlUtils] Node ${stringNode} is SINGLE variable reference. Substitute type is: ${substitute.getClass().getName()}") + // if the string node we need to do replacements for is + // a reference to a single variable, i.e. should be replaced + // entirely with the variable value, we replace the entire node + // with the variable's value (which can possibly be a complex type). + complexResult = substitute + } + else { + logger?.debug("[YamlUtils] Node ${stringNode} is multi-variable reference or contains additional string constants. Substitute type is: ${substitute.getClass().getName()}") + // if the string node we need to do replacements for contains various + // variable references or a variable reference and constant string additions + // we do a string replacement of the variables inside the node. + String regex = "\\(\\(${referenceName}\\)\\)" + stringNode = stringNode.replaceAll(regex, substitute as String) + } + } + + if (context) { + context.variablesReplaced = true // remember that variables were found in the YAML file that have been replaced. + } + + return complexResult ?: stringNode + } + else if (manifestNode instanceof List) { + List listNode = manifestNode as List + // This copy is only necessary, since Jenkins executes Groovy using + // CPS (https://wiki.jenkins.io/display/JENKINS/Pipeline+CPS+method+mismatches) + // and has issues with closures in Java 8 lambda expressions. Otherwise we could replace + // entries of the list in place (using replaceAll(lambdaExpression)) + List copy = new ArrayList<>() + listNode.each { entry -> + copy.add(substitute(entry, variableSubstitutes, context)) + } + return copy + } + else if(manifestNode instanceof Map) { + Map mapNode = manifestNode as Map + // This copy is only necessary to avoid immutability errors reported by Jenkins + // runtime environment. + Map copy = new HashMap<>() + mapNode.entrySet().each { entry -> + copy.put(entry.getKey(), substitute(entry.getValue(), variableSubstitutes, context)) + } + return copy + } + else { + logger?.debug("[YamlUtils] Found data type ${manifestNode.getClass().getName()} that needs no substitute. Value: ${manifestNode}") + return manifestNode + } + } + + /** + * Turns the parsed variables Yaml data into a + * single map. Takes care of multiple YAML sections (separated by ---) if they are found and flattens them into a single + * map if necessary. + * @param variablesYamlData - the variables data as a Yaml object. + * @return the `Map` of variable names mapped to their substitute values. + */ + private Map getVariableSubstitutes(Object variablesYamlData) { + + if(variablesYamlData instanceof List) { + return flattenVariablesFileData(variablesYamlData as List) + } + else if (variablesYamlData instanceof Map) { + return variablesYamlData as Map + } + else { + // should never happen (hopefully...) + throw new AbortException("[YamlUtils] Found unsupported data type of variables file after parsing YAML. Expected either List or Map. Got: ${variablesYamlData.getClass().getName()}.") + } + } + + /** + * Flattens a list of Yaml sections (which are deemed to be key-value mappings of variable names and values) + * to a single map. In case multiple Yaml sections contain the same key, values will be overridden and the result + * will be undefined. + * @param variablesYamlData - the `List` of Yaml objects of the different sections. + * @return the `Map` of variable substitute mappings. + */ + private Map flattenVariablesFileData(List> variablesYamlData) { + Map substitutes = new HashMap<>() + variablesYamlData.each { map -> + map.entrySet().each { entry -> + substitutes.put(entry.key, entry.value) + } + } + return substitutes + } + + /** + * Returns true, if the given object node contains variable references. + * @param node - the object-typed value to check for variable references. + * @return `true`, if this node references at least one variable, `false` otherwise. + */ + private boolean containsVariableReferences(Object node) { + if(!(node instanceof String)) { + // variable references can only be contained in + // string nodes. + return false + } + String stringNode = node as String + return stringNode.contains("((") && stringNode.contains("))") + } + + /** + * Returns true, if and only if the entire node passed in as a parameter + * is a variable reference. Returns false if the node references multiple + * variables or if the node embeds the variable reference inside of a constant + * string surrounding, e.g. `This-text-has-((numberOfWords))-words`. + * @param node - the node to check. + * @return `true` if the node is a single variable reference. `false` otherwise. + */ + private boolean isSingleVariableReference(String node) { + // regex matching only if the entire node is a reference. (^ = matches start of word, $ = matches end of word) + String regex = '^\\(\\([\\d\\w-]*\\)\\)$' // use single quote not to have to escape $ (interpolation) sign. + List matches = node.findAll(regex) + return (matches != null && !matches.isEmpty()) + } + + /** + * Returns a map of variable references (including braces) to plain variable names referenced in the given `String`. + * The keys of the map are the variable references, the values are the names of the referenced variables. + * @param value - the value to look for variable references in. + * @return the `Map` of names of referenced variables. + */ + private Map getReferencedVariables(String value) { + Map referencesNamesMap = new HashMap<>() + List variableReferences = value.findAll("\\(\\([\\d\\w-]*\\)\\)") // find all variables in braces, e.g. ((my-var_05)) + + variableReferences.each { reference -> + referencesNamesMap.put(reference, getPlainVariableName(reference)) + } + + return referencesNamesMap + } + + /** + * Expects a variable reference (including braces) as input and returns the plain name + * (by stripping braces) of the variable. E.g. input: `((my_var-04))`, output: `my_var-04` + * @param variableReference - the variable reference including braces. + * @return the plain variable name + */ + private String getPlainVariableName(String variableReference) { + String result = variableReference.replace("((", "") + result = result.replace("))", "") + return result + } +} diff --git a/test/groovy/CfManifestSubstituteVariablesTest.groovy b/test/groovy/CfManifestSubstituteVariablesTest.groovy new file mode 100644 index 000000000..fe369e6d9 --- /dev/null +++ b/test/groovy/CfManifestSubstituteVariablesTest.groovy @@ -0,0 +1,600 @@ +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.junit.Assert.* +import static util.JenkinsWriteYamlRule.DATA +import static util.JenkinsWriteYamlRule.SERIALIZED_YAML + +public class CfManifestSubstituteVariablesTest extends BasePiperTest { + + private JenkinsStepRule script = new JenkinsStepRule(this) + private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this) + private JenkinsWriteYamlRule writeYamlRule = new JenkinsWriteYamlRule(this) + private JenkinsErrorRule errorRule = new JenkinsErrorRule(this) + private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private ExpectedException expectedExceptionRule = ExpectedException.none() + private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this, []) + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(fileExistsRule) + .around(readYamlRule) + .around(writeYamlRule) + .around(errorRule) + .around(environmentRule) + .around(loggingRule) + .around(script) + .around(expectedExceptionRule) + + @Before + public void setup() { + readYamlRule.registerYaml("manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest.yml"))) + .registerYaml("manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest-variables.yml"))) + .registerYaml("test/resources/variableSubstitution/manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest-variables.yml"))) + .registerYaml("test/resources/variableSubstitution/invalid_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/invalid_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/novars_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/novars_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/multi_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/multi_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/datatypes_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/datatypes_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/datatypes_manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/datatypes_manifest-variables.yml"))) + .registerYaml("test/resources/variableSubstitution/manifest-variables-conflicting.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest-variables-conflicting.yml"))) + } + + @Test + public void substituteVariables_SkipsExecution_If_ManifestNotPresent() throws Exception { + String manifestFileName = "nonexistent/manifest.yml" + String variablesFileName = "nonexistent/manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Could not find YAML file at ${manifestFileName}. Skipping variable substitution.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_SkipsExecution_If_VariablesFileNotPresent() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "nonexistent/manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Did not find manifest variable substitution file at ${variablesFileName}.") + expectedExceptionRule.expect(hudson.AbortException) + expectedExceptionRule.expectMessage("[CFManifestSubstituteVariables] Could not find all given manifest variable substitution files. Make sure all files given as manifestVariablesFiles exist.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_Throws_If_manifestInvalid() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + //check that exception is thrown and that it has the correct message. + expectedExceptionRule.expect(org.yaml.snakeyaml.scanner.ScannerException) + expectedExceptionRule.expectMessage("found character '%' that cannot start any token. (Do not use % for indentation)") + + loggingRule.expect("[CFManifestSubstituteVariables] Could not load manifest file at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_Throws_If_manifestVariablesInvalid() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + //check that exception is thrown and that it has the correct message. + expectedExceptionRule.expect(org.yaml.snakeyaml.scanner.ScannerException) + expectedExceptionRule.expectMessage("found character '%' that cannot start any token. (Do not use % for indentation)") + + loggingRule.expect("[CFManifestSubstituteVariables] Could not load manifest variables file at ${variablesFileName}") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_UsesDefaultFileName_If_NoManifestSpecified() throws Exception { + // In this test, we check that the implementation will resort to the default manifest file name. + // Since the file is not present, the implementation should stop, but the log should indicate that the + // the default file name was used. + + String manifestFileName = "manifest.yml" // default name should be chosen. + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Could not find YAML file at ${manifestFileName}. Skipping variable substitution.") + + // execute step + script.step.cfManifestSubstituteVariables script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_UsesDefaultFileName_If_NoVariablesFileSpecified() throws Exception { + // In this test, we check that the implementation will resort to the default manifest _variables_ file name. + // Since the file is not present, the implementation should stop, but the log should indicate that the + // the default file name was used. + + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "manifest-variables.yml" // default file name that should be chosen. + + fileExistsRule.registerExistingFile(manifestFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Did not find manifest variable substitution file at ${variablesFileName}.") + .expect("[CFManifestSubstituteVariables] Could not find any default manifest variable substitution file at ${variablesFileName}, and no manifest variables list was specified. Skipping variable substitution.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_ReplacesVariablesProperly_InSingleYamlFiles() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + Map manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that there are no unresolved variables left. + assertAllVariablesReplaced(yamlStringAfterReplacement) + + // check that resolved variables have expected values + assertCorrectVariableResolution(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + private void assertAllVariablesReplaced(String yamlStringAfterReplacement) { + assertFalse(yamlStringAfterReplacement.contains("((")) + assertFalse(yamlStringAfterReplacement.contains("))")) + } + + private void assertCorrectVariableResolution(Map manifestDataAfterReplacement) { + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("name").equals("uniquePrefix-catalog-service-odatav2-0.0.1")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("routes").get(0).get("route").equals("uniquePrefix-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(0).equals("uniquePrefix-catalog-service-odatav2-xsuaa")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(1).equals("uniquePrefix-catalog-service-odatav2-hana")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("xsuaa-instance-name").equals("uniquePrefix-catalog-service-odatav2-xsuaa")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("db_service_instance_name").equals("uniquePrefix-catalog-service-odatav2-hana")) + } + + @Test + public void substituteVariables_ReplacesVariablesProperly_InMultiYamlFiles() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/multi_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + List manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that there are no unresolved variables left. + assertAllVariablesReplaced(yamlStringAfterReplacement) + + //check that result still is a multi-YAML file. + assertEquals("Dumped YAML after replacement should still be a multi-YAML file.",2, manifestDataAfterReplacement.size()) + + // check that resolved variables have expected values + manifestDataAfterReplacement.each { yaml -> + assertCorrectVariableResolution(yaml as Map) + } + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_ReplacesVariablesFromListProperly_whenNoManifestVariablesFilesGiven_InMultiYamlFiles() throws Exception { + // This test replaces variables in multi-yaml files from a list of specified variables + // the the user has not specified any variable substitution files list. + + String manifestFileName = "test/resources/variableSubstitution/multi_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + + List> variablesList = [ + ["unique-prefix" : "uniquePrefix"], + ["xsuaa-instance-name" : "uniquePrefix-catalog-service-odatav2-xsuaa"], + ["hana-instance-name" : "uniquePrefix-catalog-service-odatav2-hana"] + ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + //.expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariables: variablesList, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + List manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that there are no unresolved variables left. + assertAllVariablesReplaced(yamlStringAfterReplacement) + + //check that result still is a multi-YAML file. + assertEquals("Dumped YAML after replacement should still be a multi-YAML file.",2, manifestDataAfterReplacement.size()) + + // check that resolved variables have expected values + manifestDataAfterReplacement.each { yaml -> + assertCorrectVariableResolution(yaml as Map) + } + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_ReplacesVariablesFromListProperly_whenEmptyManifestVariablesFilesGiven_InMultiYamlFiles() throws Exception { + // This test replaces variables in multi-yaml files from a list of specified variables + // the the user has specified an empty list of variable substitution files. + + String manifestFileName = "test/resources/variableSubstitution/multi_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + List variablesFiles = [] + + List> variablesList = [ + ["unique-prefix" : "uniquePrefix"], + ["xsuaa-instance-name" : "uniquePrefix-catalog-service-odatav2-xsuaa"], + ["hana-instance-name" : "uniquePrefix-catalog-service-odatav2-hana"] + ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + //.expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, manifestVariables: variablesList, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + List manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that there are no unresolved variables left. + assertAllVariablesReplaced(yamlStringAfterReplacement) + + //check that result still is a multi-YAML file. + assertEquals("Dumped YAML after replacement should still be a multi-YAML file.",2, manifestDataAfterReplacement.size()) + + // check that resolved variables have expected values + manifestDataAfterReplacement.each { yaml -> + assertCorrectVariableResolution(yaml as Map) + } + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_SkipsExecution_If_NoVariablesInManifest() throws Exception { + // This test makes sure that, if no variables are found in a manifest that need + // to be replaced, the execution is eventually skipped and the manifest remains + // untouched. + + String manifestFileName = "test/resources/variableSubstitution/novars_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] No variables were found or could be replaced in ${manifestFileName}. Skipping variable substitution.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + //Check that nothing was written + assertNull(writeYamlRule.files[manifestFileName]) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_SupportsAllDataTypes() throws Exception { + // This test makes sure that, all datatypes supported by YAML are also + // properly substituted by the substituteVariables step. + // In particular this includes variables of type: + // Integer, Boolean, String, Float and inline JSON documents (which are parsed as multi-line strings) + // and complex types (like other YAML objects). + // The test also checks the differing behaviour when substituting nodes that only consist of a + // variable reference and nodes that contains several variable references or additional string constants. + + String manifestFileName = "test/resources/variableSubstitution/datatypes_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/datatypes_manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + Map manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + assertAllVariablesReplaced(yamlStringAfterReplacement) + assertCorrectVariableResolution(manifestDataAfterReplacement) + + assertDataTypeAndSubstitutionCorrectness(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + private void assertDataTypeAndSubstitutionCorrectness(Map manifestDataAfterReplacement) { + // See datatypes_manifest.yml and datatypes_manifest-variables.yml. + // Note: For debugging consider turning on YAML writing to a file in JenkinsWriteYamlRule to see the + // actual outcome of replacing variables (for visual inspection). + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("instances").equals(1)) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("instances") instanceof Integer) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(0) instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("booleanVariable").equals(true)) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("booleanVariable") instanceof Boolean) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("floatVariable") == 0.25) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("floatVariable") instanceof Double) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("json-variable") instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("object-variable") instanceof Map) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("string-variable").startsWith("true-0.25-1-")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("string-variable") instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("single-var-with-string-constants").equals("true-with-some-more-text")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("single-var-with-string-constants") instanceof String) + } + + @Test + public void substituteVariables_replacesManifestIfNoOutputGiven() throws Exception { + // Test that makes sure that the original input manifest file is replaced with + // a version that has variables replaced (by deleting the original file and + // dumping a new one with the same name). + + String manifestFileName = "test/resources/variableSubstitution/datatypes_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/datatypes_manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Successfully deleted file '${manifestFileName}'.") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + Map manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + assertAllVariablesReplaced(yamlStringAfterReplacement) + assertCorrectVariableResolution(manifestDataAfterReplacement) + + assertDataTypeAndSubstitutionCorrectness(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_writesToOutputFileIfGiven() throws Exception { + // Test that makes sure that the output is written to the specified file and that the original input manifest + // file is NOT deleted / replaced. + + String manifestFileName = "test/resources/variableSubstitution/datatypes_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/datatypes_manifest-variables.yml" + List variablesFiles = [ variablesFileName ] + String outputFileName = "output.yml" + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + .expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${outputFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, outputManifestFile: outputFileName, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[outputFileName].get(SERIALIZED_YAML) as String + Map manifestDataAfterReplacement = writeYamlRule.files[outputFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // make sure the input file was NOT deleted. + assertFalse(loggingRule.expected.contains("[CFManifestSubstituteVariables] Successfully deleted file '${manifestFileName}'.")) + + assertAllVariablesReplaced(yamlStringAfterReplacement) + assertCorrectVariableResolution(manifestDataAfterReplacement) + + assertDataTypeAndSubstitutionCorrectness(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_resolvesConflictsProperly_InMultiYamlFiles() throws Exception { + // This test checks if the resolution and of conflicting variables and the + // overriding nature of variable lists vs. variable files lists is working properly. + + String manifestFileName = "test/resources/variableSubstitution/multi_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + String conflictingVariablesFileName = "test/resources/variableSubstitution/manifest-variables-conflicting.yml" + List variablesFiles = [ variablesFileName, conflictingVariablesFileName ] //introducing a conflicting file whose entries should win, since it is last in the list + + List> variablesList = [ + ["unique-prefix" : "uniquePrefix-from-vars-list"], + ["unique-prefix" : "uniquePrefix-from-vars-list-conflicting"] // introduce a conflict that should win, since it is last in the list. + ] + + fileExistsRule.registerExistingFile(manifestFileName) + fileExistsRule.registerExistingFile(variablesFileName) + fileExistsRule.registerExistingFile(conflictingVariablesFileName) + + // check that a proper log is written. + loggingRule.expect("[CFManifestSubstituteVariables] Loaded manifest at ${manifestFileName}!") + //.expect("[CFManifestSubstituteVariables] Loaded variables file at ${variablesFileName}!") + .expect("[CFManifestSubstituteVariables] Replaced variables in ${manifestFileName}.") + .expect("[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${manifestFileName}.") + + // execute step + script.step.cfManifestSubstituteVariables manifestFile: manifestFileName, manifestVariablesFiles: variablesFiles, manifestVariables: variablesList, script: nullScript + + String yamlStringAfterReplacement = writeYamlRule.files[manifestFileName].get(SERIALIZED_YAML) as String + List manifestDataAfterReplacement = writeYamlRule.files[manifestFileName].get(DATA) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that there are no unresolved variables left. + assertAllVariablesReplaced(yamlStringAfterReplacement) + + //check that result still is a multi-YAML file. + assertEquals("Dumped YAML after replacement should still be a multi-YAML file.",2, manifestDataAfterReplacement.size()) + + // check that resolved variables have expected values + manifestDataAfterReplacement.each { yaml -> + assertCorrectVariableSubstitutionUnderConflictAndWithOverriding(yaml as Map) + } + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + private void assertCorrectVariableSubstitutionUnderConflictAndWithOverriding(Map manifestDataAfterReplacement) { + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("name").equals("uniquePrefix-from-vars-list-conflicting-catalog-service-odatav2-0.0.1")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("routes").get(0).get("route").equals("uniquePrefix-from-vars-list-conflicting-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(0).equals("uniquePrefix-catalog-service-odatav2-xsuaa-conflicting-from-file")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(1).equals("uniquePrefix-catalog-service-odatav2-hana")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("xsuaa-instance-name").equals("uniquePrefix-catalog-service-odatav2-xsuaa-conflicting-from-file")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("db_service_instance_name").equals("uniquePrefix-catalog-service-odatav2-hana")) + } +} diff --git a/test/groovy/com/sap/piper/variablesubstitution/YamlUtilsTest.groovy b/test/groovy/com/sap/piper/variablesubstitution/YamlUtilsTest.groovy new file mode 100644 index 000000000..7f6d8804f --- /dev/null +++ b/test/groovy/com/sap/piper/variablesubstitution/YamlUtilsTest.groovy @@ -0,0 +1,271 @@ +package com.sap.piper.variablesubstitution + +import org.junit.Before + +import static org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; +import util.BasePiperTest +import util.JenkinsEnvironmentRule +import util.JenkinsErrorRule +import util.JenkinsLoggingRule +import util.JenkinsReadYamlRule +import util.JenkinsWriteYamlRule +import util.Rules + +class YamlUtilsTest extends BasePiperTest { + + private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this) + private JenkinsWriteYamlRule writeYamlRule = new JenkinsWriteYamlRule(this) + private JenkinsErrorRule errorRule = new JenkinsErrorRule(this) + private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private ExpectedException expectedExceptionRule = ExpectedException.none() + + private YamlUtils yamlUtils + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(readYamlRule) + .around(writeYamlRule) + .around(errorRule) + .around(environmentRule) + .around(loggingRule) + .around(expectedExceptionRule) + + @Before + public void setup() { + yamlUtils = new YamlUtils(nullScript) + + readYamlRule.registerYaml("manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest.yml"))) + .registerYaml("manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest-variables.yml"))) + .registerYaml("test/resources/variableSubstitution/manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/manifest-variables.yml"))) + .registerYaml("test/resources/variableSubstitution/invalid_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/invalid_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/novars_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/novars_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/multi_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/multi_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/datatypes_manifest.yml", new FileInputStream(new File("test/resources/variableSubstitution/datatypes_manifest.yml"))) + .registerYaml("test/resources/variableSubstitution/datatypes_manifest-variables.yml", new FileInputStream(new File("test/resources/variableSubstitution/datatypes_manifest-variables.yml"))) + } + + @Test + public void substituteVariables_Fails_If_InputYamlIsNullOrEmpty() throws Exception { + + expectedExceptionRule.expect(IllegalArgumentException) + expectedExceptionRule.expectMessage("[YamlUtils] Input Yaml data must not be null or empty.") + + yamlUtils.substituteVariables(null, null) + } + + @Test + public void substituteVariables_Fails_If_VariablesYamlIsNullOrEmpty() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + + expectedExceptionRule.expect(IllegalArgumentException) + expectedExceptionRule.expectMessage("[YamlUtils] Variables Yaml data must not be null or empty.") + + Object input = nullScript.readYaml file: manifestFileName + + // execute step + yamlUtils.substituteVariables(input, null) + } + + @Test + public void substituteVariables_Throws_If_InputYamlIsInvalid() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + + //check that exception is thrown and that it has the correct message. + expectedExceptionRule.expect(org.yaml.snakeyaml.scanner.ScannerException) + expectedExceptionRule.expectMessage("found character '%' that cannot start any token. (Do not use % for indentation)") + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + yamlUtils.substituteVariables(input, variables) + } + + @Test + public void substituteVariables_Throws_If_VariablesYamlInvalid() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/invalid_manifest.yml" + + //check that exception is thrown and that it has the correct message. + expectedExceptionRule.expect(org.yaml.snakeyaml.scanner.ScannerException) + expectedExceptionRule.expectMessage("found character '%' that cannot start any token. (Do not use % for indentation)") + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + yamlUtils.substituteVariables(input, variables) + } + + @Test + public void substituteVariables_ReplacesVariablesProperly_InSingleYamlFiles() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + Map manifestDataAfterReplacement = yamlUtils.substituteVariables(input, variables) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that resolved variables have expected values + assertCorrectVariableResolution(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + private void assertAllVariablesReplaced(String yamlStringAfterReplacement) { + assertFalse(yamlStringAfterReplacement.contains("((")) + assertFalse(yamlStringAfterReplacement.contains("))")) + } + + private void assertCorrectVariableResolution(Map manifestDataAfterReplacement) { + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("name").equals("uniquePrefix-catalog-service-odatav2-0.0.1")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("routes").get(0).get("route").equals("uniquePrefix-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(0).equals("uniquePrefix-catalog-service-odatav2-xsuaa")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(1).equals("uniquePrefix-catalog-service-odatav2-hana")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("xsuaa-instance-name").equals("uniquePrefix-catalog-service-odatav2-xsuaa")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("db_service_instance_name").equals("uniquePrefix-catalog-service-odatav2-hana")) + } + + @Test + public void substituteVariables_ReplacesVariablesProperly_InMultiYamlData() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/multi_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + List manifestDataAfterReplacement = yamlUtils.substituteVariables(input, variables) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + //check that result still is a multi-YAML file. + assertEquals("Dumped YAML after replacement should still be a multi-YAML file.",2, manifestDataAfterReplacement.size()) + + // check that resolved variables have expected values + manifestDataAfterReplacement.each { yaml -> + assertCorrectVariableResolution(yaml as Map) + } + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_ReturnsOriginalIfNoVariablesPresent() throws Exception { + // This test makes sure that, if no variables are found in a manifest that need + // to be replaced, the execution is eventually skipped and the manifest remains + // untouched. + + String manifestFileName = "test/resources/variableSubstitution/novars_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + ExecutionContext context = new ExecutionContext() + Object result = yamlUtils.substituteVariables(input, variables, context) + + //Check that nothing was written + assertNotNull(result) + assertFalse(context.variablesReplaced) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + @Test + public void substituteVariables_SupportsAllDataTypes() throws Exception { + // This test makes sure that, all datatypes supported by YAML are also + // properly substituted by the substituteVariables step. + // In particular this includes variables of type: + // Integer, Boolean, String, Float and inline JSON documents (which are parsed as multi-line strings) + // and complex types (like other YAML objects). + // The test also checks the differing behaviour when substituting nodes that only consist of a + // variable reference and nodes that contains several variable references or additional string constants. + + String manifestFileName = "test/resources/variableSubstitution/datatypes_manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/datatypes_manifest-variables.yml" + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + ExecutionContext context = new ExecutionContext() + Map manifestDataAfterReplacement = yamlUtils.substituteVariables(input, variables, context) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + assertCorrectVariableResolution(manifestDataAfterReplacement) + + assertDataTypeAndSubstitutionCorrectness(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } + + private void assertDataTypeAndSubstitutionCorrectness(Map manifestDataAfterReplacement) { + // See datatypes_manifest.yml and datatypes_manifest-variables.yml. + // Note: For debugging consider turning on YAML writing to a file in JenkinsWriteYamlRule to see the + // actual outcome of replacing variables (for visual inspection). + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("instances").equals(1)) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("instances") instanceof Integer) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("services").get(0) instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("booleanVariable").equals(true)) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("booleanVariable") instanceof Boolean) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("floatVariable") == 0.25) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("floatVariable") instanceof Double) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("json-variable") instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("object-variable") instanceof Map) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("string-variable").startsWith("true-0.25-1-")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("string-variable") instanceof String) + + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("single-var-with-string-constants").equals("true-with-some-more-text")) + assertTrue(manifestDataAfterReplacement.get("applications").get(0).get("env").get("single-var-with-string-constants") instanceof String) + } + + @Test + public void substituteVariables_DoesNotFail_If_ExecutionContextIsNull() throws Exception { + String manifestFileName = "test/resources/variableSubstitution/manifest.yml" + String variablesFileName = "test/resources/variableSubstitution/manifest-variables.yml" + + Object input = nullScript.readYaml file: manifestFileName + Object variables = nullScript.readYaml file: variablesFileName + + // execute step + Map manifestDataAfterReplacement = yamlUtils.substituteVariables(input, variables, null) + + //Check that something was written + assertNotNull(manifestDataAfterReplacement) + + // check that resolved variables have expected values + assertCorrectVariableResolution(manifestDataAfterReplacement) + + // check that the step was marked as a success (even if it did do nothing). + assertJobStatusSuccess() + } +} diff --git a/test/groovy/util/JenkinsFileExistsRule.groovy b/test/groovy/util/JenkinsFileExistsRule.groovy index 9f88c38a0..cc01c48cc 100644 --- a/test/groovy/util/JenkinsFileExistsRule.groovy +++ b/test/groovy/util/JenkinsFileExistsRule.groovy @@ -10,11 +10,21 @@ class JenkinsFileExistsRule implements TestRule { final BasePipelineTest testInstance final List existingFiles + /** + * The List of files that have been queried via `fileExists` + */ + final List queriedFiles = [] + JenkinsFileExistsRule(BasePipelineTest testInstance, List existingFiles) { this.testInstance = testInstance this.existingFiles = existingFiles } + JenkinsFileExistsRule registerExistingFile(String file) { + existingFiles.add(file) + return this + } + @Override Statement apply(Statement base, Description description) { return statement(base) @@ -25,8 +35,15 @@ class JenkinsFileExistsRule implements TestRule { @Override void evaluate() throws Throwable { - testInstance.helper.registerAllowedMethod('fileExists', [String.class], {s -> return s in existingFiles}) - testInstance.helper.registerAllowedMethod('fileExists', [Map.class], {m -> return m.file in existingFiles}) + testInstance.helper.registerAllowedMethod('fileExists', [String.class], {s -> + queriedFiles.add(s) + return s in existingFiles + }) + + testInstance.helper.registerAllowedMethod('fileExists', [Map.class], {m -> + queriedFiles.add(m.file) + return m.file in existingFiles} + ) base.evaluate() } diff --git a/test/groovy/util/JenkinsLoggingRule.groovy b/test/groovy/util/JenkinsLoggingRule.groovy index f4a4f3d92..6e391b23b 100644 --- a/test/groovy/util/JenkinsLoggingRule.groovy +++ b/test/groovy/util/JenkinsLoggingRule.groovy @@ -24,8 +24,9 @@ class JenkinsLoggingRule implements TestRule { this.testInstance = testInstance } - public void expect(String substring) { + public JenkinsLoggingRule expect(String substring) { expected.add(substring) + return this } @Override diff --git a/test/groovy/util/JenkinsReadYamlRule.groovy b/test/groovy/util/JenkinsReadYamlRule.groovy index 30e74f460..de239a640 100644 --- a/test/groovy/util/JenkinsReadYamlRule.groovy +++ b/test/groovy/util/JenkinsReadYamlRule.groovy @@ -42,11 +42,37 @@ class JenkinsReadYamlRule implements TestRule { } else { throw new IllegalArgumentException("Key 'text' and 'file' are both missing in map ${m}.") } - return new Yaml().load(yml) + + + return readYaml(yml) }) base.evaluate() } } } + + /** + * Mimicking code of the original library (link below). + *

+ * Yaml files may contain several YAML sections, separated by ---. + * This loads them all and returns a {@code List} of entries in case multiple sections were found or just + * a single {@code Object}, if only one section was read. + * @see https://github.com/jenkinsci/pipeline-utility-steps-plugin/blob/master/src/main/java/org/jenkinsci/plugins/pipeline/utility/steps/conf/ReadYamlStep.java + */ + private def readYaml(def yml) { + Iterable yaml = new Yaml().loadAll(yml) + + List result = new LinkedList() + for (Object data : yaml) { + result.add(data) + } + + // If only one YAML document, return it directly + if (result.size() == 1) { + return result.get(0); + } + + return result; + } } diff --git a/test/groovy/util/JenkinsWriteYamlRule.groovy b/test/groovy/util/JenkinsWriteYamlRule.groovy new file mode 100644 index 000000000..7abe60389 --- /dev/null +++ b/test/groovy/util/JenkinsWriteYamlRule.groovy @@ -0,0 +1,59 @@ +package util + +import com.lesfurets.jenkins.unit.BasePipelineTest +import org.yaml.snakeyaml.Yaml + +import static org.junit.Assert.* +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class JenkinsWriteYamlRule implements TestRule { + + final BasePipelineTest testInstance + static final String DATA = "DATA" // key in files map to retrieve Yaml object graph data.. + static final String CHARSET = "CHARSET" // key in files map to retrieve the charset of the serialized Yaml. + static final String SERIALIZED_YAML = "SERIALIZED_YAML" // key in files map to retrieve serialized Yaml. + + Map> files = new HashMap<>() + + JenkinsWriteYamlRule(BasePipelineTest testInstance) { + this.testInstance = testInstance + } + + @Override + Statement apply(Statement base, Description description) { + return statement(base) + } + + private Statement statement(final Statement base) { + return new Statement() { + @Override + void evaluate() throws Throwable { + + testInstance.helper.registerAllowedMethod( 'writeYaml', [Map], { parameterMap -> + assertNotNull(parameterMap.file) + assertNotNull(parameterMap.data) + // charset is optional. + + Yaml yaml = new Yaml() + StringWriter writer = new StringWriter() + yaml.dump(parameterMap.data, writer) + + // Enable this to actually produce a file. + // yaml.dump(parameterMap.data, new FileWriter(parameterMap.file)) + // yaml.dump(parameterMap.data, new FileWriter("test/resources/variableSubstitution/manifest_out.yml")) + + Map details = new HashMap<>() + details.put(DATA, parameterMap.data) + details.put(CHARSET, parameterMap.charset ?: "UTF-8") + details.put(SERIALIZED_YAML, writer.toString()) + + files[parameterMap.file] = details + }) + + base.evaluate() + } + } + } +} diff --git a/test/resources/variableSubstitution/datatypes_manifest-variables.yml b/test/resources/variableSubstitution/datatypes_manifest-variables.yml new file mode 100644 index 000000000..1d54653f4 --- /dev/null +++ b/test/resources/variableSubstitution/datatypes_manifest-variables.yml @@ -0,0 +1,19 @@ +--- +unique-prefix: uniquePrefix # A unique prefix. E.g. your D/I/C-User +xsuaa-instance-name: uniquePrefix-catalog-service-odatav2-xsuaa +hana-instance-name: uniquePrefix-catalog-service-odatav2-hana +integer-variable: 1 +boolean-variable: Yes +float-variable: 0.25 +json-variable: > + [ + {"name":"token-destination", + "url":"https://www.google.com", + "forwardAuthToken": true} + ] +object-variable: + hello: "world" + this: "is an object with" + one: 1 + float: 25.0 + bool: Yes diff --git a/test/resources/variableSubstitution/datatypes_manifest.yml b/test/resources/variableSubstitution/datatypes_manifest.yml new file mode 100644 index 000000000..591872fa7 --- /dev/null +++ b/test/resources/variableSubstitution/datatypes_manifest.yml @@ -0,0 +1,27 @@ +--- +applications: +- name: ((unique-prefix))-catalog-service-odatav2-0.0.1 + memory: 1024M + disk_quota: 512M + instances: ((integer-variable)) + buildpacks: + - java_buildpack + path: ./srv/target/srv-backend-0.0.1-SNAPSHOT.jar + + routes: + - route: ((unique-prefix))-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com + + services: + - ((xsuaa-instance-name)) # requires an instance of xsuaa instantiated with xs-security.json of this project. See services-manifest.yml. + - ((hana-instance-name)) # requires an instance of hana service with plan hdi-shared. See services-manifest.yml. + + env: + spring.profiles.active: cloud # activate the spring profile named 'cloud'. + xsuaa-instance-name: ((xsuaa-instance-name)) + db_service_instance_name: ((hana-instance-name)) + booleanVariable: ((boolean-variable)) + floatVariable: ((float-variable)) + json-variable: ((json-variable)) + object-variable: ((object-variable)) + string-variable: ((boolean-variable))-((float-variable))-((integer-variable))-((json-variable)) + single-var-with-string-constants: ((boolean-variable))-with-some-more-text diff --git a/test/resources/variableSubstitution/invalid_manifest.yml b/test/resources/variableSubstitution/invalid_manifest.yml new file mode 100644 index 000000000..108f733da --- /dev/null +++ b/test/resources/variableSubstitution/invalid_manifest.yml @@ -0,0 +1,2 @@ +--- +test: %invalid diff --git a/test/resources/variableSubstitution/manifest-variables-conflicting.yml b/test/resources/variableSubstitution/manifest-variables-conflicting.yml new file mode 100644 index 000000000..4e12a173b --- /dev/null +++ b/test/resources/variableSubstitution/manifest-variables-conflicting.yml @@ -0,0 +1,4 @@ +--- +unique-prefix: uniquePrefix-conflicting-from-file # A unique prefix. E.g. your D/I/C-User +xsuaa-instance-name: uniquePrefix-catalog-service-odatav2-xsuaa-conflicting-from-file + diff --git a/test/resources/variableSubstitution/manifest-variables.yml b/test/resources/variableSubstitution/manifest-variables.yml new file mode 100644 index 000000000..24f697960 --- /dev/null +++ b/test/resources/variableSubstitution/manifest-variables.yml @@ -0,0 +1,4 @@ +--- +unique-prefix: uniquePrefix # A unique prefix. E.g. your D/I/C-User +xsuaa-instance-name: uniquePrefix-catalog-service-odatav2-xsuaa +hana-instance-name: uniquePrefix-catalog-service-odatav2-hana \ No newline at end of file diff --git a/test/resources/variableSubstitution/manifest.yml b/test/resources/variableSubstitution/manifest.yml new file mode 100644 index 000000000..6aecf1d4f --- /dev/null +++ b/test/resources/variableSubstitution/manifest.yml @@ -0,0 +1,21 @@ +--- +applications: +- name: ((unique-prefix))-catalog-service-odatav2-0.0.1 + memory: 1024M + disk_quota: 512M + instances: 1 + buildpacks: + - java_buildpack + path: ./srv/target/srv-backend-0.0.1-SNAPSHOT.jar + + routes: + - route: ((unique-prefix))-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com + + services: + - ((xsuaa-instance-name)) # requires an instance of xsuaa instantiated with xs-security.json of this project. See services-manifest.yml. + - ((hana-instance-name)) # requires an instance of hana service with plan hdi-shared. See services-manifest.yml. + + env: + spring.profiles.active: cloud # activate the spring profile named 'cloud'. + xsuaa-instance-name: ((xsuaa-instance-name)) + db_service_instance_name: ((hana-instance-name)) \ No newline at end of file diff --git a/test/resources/variableSubstitution/multi_manifest.yml b/test/resources/variableSubstitution/multi_manifest.yml new file mode 100644 index 000000000..a852fe4ec --- /dev/null +++ b/test/resources/variableSubstitution/multi_manifest.yml @@ -0,0 +1,43 @@ +--- +applications: +- name: ((unique-prefix))-catalog-service-odatav2-0.0.1 + memory: 1024M + disk_quota: 512M + instances: 1 + buildpacks: + - java_buildpack + path: ./srv/target/srv-backend-0.0.1-SNAPSHOT.jar + + routes: + - route: ((unique-prefix))-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com + + services: + - ((xsuaa-instance-name)) # requires an instance of xsuaa instantiated with xs-security.json of this project. See services-manifest.yml. + - ((hana-instance-name)) # requires an instance of hana service with plan hdi-shared. See services-manifest.yml. + + env: + spring.profiles.active: cloud # activate the spring profile named 'cloud'. + xsuaa-instance-name: ((xsuaa-instance-name)) + db_service_instance_name: ((hana-instance-name)) + +--- +applications: + - name: ((unique-prefix))-catalog-service-odatav2-0.0.1 + memory: 1024M + disk_quota: 512M + instances: 1 + buildpacks: + - java_buildpack + path: ./srv/target/srv-backend-0.0.1-SNAPSHOT.jar + + routes: + - route: ((unique-prefix))-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com + + services: + - ((xsuaa-instance-name)) # requires an instance of xsuaa instantiated with xs-security.json of this project. See services-manifest.yml. + - ((hana-instance-name)) # requires an instance of hana service with plan hdi-shared. See services-manifest.yml. + + env: + spring.profiles.active: cloud # activate the spring profile named 'cloud'. + xsuaa-instance-name: ((xsuaa-instance-name)) + db_service_instance_name: ((hana-instance-name)) diff --git a/test/resources/variableSubstitution/novars_manifest.yml b/test/resources/variableSubstitution/novars_manifest.yml new file mode 100644 index 000000000..7869f00da --- /dev/null +++ b/test/resources/variableSubstitution/novars_manifest.yml @@ -0,0 +1,21 @@ +--- +applications: +- name: test-catalog-service-odatav2-0.0.1 + memory: 1024M + disk_quota: 512M + instances: 1 + buildpacks: + - java_buildpack + path: ./srv/target/srv-backend-0.0.1-SNAPSHOT.jar + + routes: + - route: test-catalog-service-odatav2-001.cfapps.eu10.hana.ondemand.com + + services: + - xsuaa-instance-name # requires an instance of xsuaa instantiated with xs-security.json of this project. See services-manifest.yml. + - hana-instance-name # requires an instance of hana service with plan hdi-shared. See services-manifest.yml. + + env: + spring.profiles.active: cloud # activate the spring profile named 'cloud'. + xsuaa-instance-name: xsuaa-instance-name + db_service_instance_name: hana-instance-name diff --git a/vars/cfManifestSubstituteVariables.groovy b/vars/cfManifestSubstituteVariables.groovy new file mode 100644 index 000000000..50d7c14b1 --- /dev/null +++ b/vars/cfManifestSubstituteVariables.groovy @@ -0,0 +1,264 @@ +import com.sap.piper.ConfigurationHelper +import com.sap.piper.GenerateDocumentation +import com.sap.piper.variablesubstitution.ExecutionContext +import com.sap.piper.variablesubstitution.DebugHelper +import com.sap.piper.variablesubstitution.YamlUtils +import groovy.transform.Field + +import static com.sap.piper.Prerequisites.checkScript + +@Field String STEP_NAME = getClass().getName() +@Field Set GENERAL_CONFIG_KEYS = [] +@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS + [ + /** + * The `String` path of the Yaml file to replace variables in. + * Defaults to "manifest.yml" if not specified otherwise. + */ + 'manifestFile', + /** + * The `String` path of the Yaml file to produce as output. + * If not specified this will default to `manifestFile` and overwrite it. + */ + 'outputManifestFile', + /** + * The `List` of `String` paths of the Yaml files containing the variable values to use as a replacement in the manifest file. + * Defaults to `["manifest-variables.yml"]` if not specified otherwise. The order of the files given in the list is relevant + * in case there are conflicting variable names and values within variable files. In such a case, the values of the last file win. + */ + 'manifestVariablesFiles', + /** + * A `List` of `Map` entries for key-value pairs used for variable substitution within the file given by `manifestFile`. + * Defaults to an empty list, if not specified otherwise. This can be used to set variables like it is provided + * by `cf push --var key=value`. + * + * The order of the maps of variables given in the list is relevant in case there are conflicting variable names and values + * between maps contained within the list. In case of conflicts, the last specified map in the list will win. + * + * Though each map entry in the list can contain more than one key-value pair for variable substitution, it is recommended + * to stick to one entry per map, and rather declare more maps within the list. The reason is that + * if a map in the list contains more than one key-value entry, and the entries are conflicting, the + * conflict resolution behavior is undefined (since map entries have no sequence). + * + * Note: variables defined via `manifestVariables` always win over conflicting variables defined via any file given + * by `manifestVariablesFiles` - no matter what is declared before. This reproduces the same behavior as can be + * observed when using `cf push --var` in combination with `cf push --vars-file`. + */ + 'manifestVariables' +] + +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +/* + * Step to substitute variables in a given YAML file with those specified in one or more variables files given by the + * `manifestVariablesFiles` parameter. This follows the behavior of `cf push --vars-file`, and can be + * used as a pre-deployment step if commands other than `cf push` are used for deployment (e.g. `cf blue-green-deploy`). + * + * The format to reference a variable in the manifest YAML file is to use double parentheses `((` and `))`, e.g. `((variableName))`. + * + * You can declare variable assignments as key value-pairs inside a YAML variables file following the + * [Cloud Foundry standards](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#variable-substitution) format. + * + * Optionally, you can also specify a direct list of key-value mappings for variables using the `manifestVariables` parameter. + * Variables given in the `manifestVariables` list will take precedence over those found in variables files. This follows + * the behavior of `cf push --var`, and works in combination with `manifestVariablesFiles`. + * + * The step is activated by the presence of the file specified by the `manifestFile` parameter and all variables files + * specified by the `manifestVariablesFiles` parameter, or if variables are passed in directly via `manifestVariables`. + * + * In case no `manifestVariablesFiles` were explicitly specified, a default named `manifest-variables.yml` will be looked + * for and if present will activate this step also. This is to support convention over configuration. + */ +@GenerateDocumentation +void call(Map arguments = [:]) { + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: arguments) { + def script = checkScript(this, arguments) ?: this + + // load default & individual configuration + Map config = ConfigurationHelper.newInstance(this) + .loadStepDefaults() + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, arguments.stageName ?: env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixin(arguments, PARAMETER_KEYS) + .use() + + String defaultManifestFileName = "manifest.yml" + String defaultManifestVariablesFileName = "manifest-variables.yml" + + Boolean manifestVariablesFilesExplicitlySpecified = config.manifestVariablesFiles != null + + String manifestFilePath = config.manifestFile ?: defaultManifestFileName + List manifestVariablesFiles = (config.manifestVariablesFiles != null) ? config.manifestVariablesFiles : [ defaultManifestVariablesFileName ] + List> manifestVariablesList = config.manifestVariables ?: [] + String outputFilePath = config.outputManifestFile ?: manifestFilePath + + DebugHelper debugHelper = new DebugHelper(script, config) + YamlUtils yamlUtils = new YamlUtils(script, debugHelper) + + Boolean manifestExists = fileExists manifestFilePath + Boolean manifestVariablesFilesExist = allManifestVariableFilesExist(manifestVariablesFiles) + Boolean manifestVariablesListSpecified = !manifestVariablesList.isEmpty() + + if (!manifestExists) { + echo "[CFManifestSubstituteVariables] Could not find YAML file at ${manifestFilePath}. Skipping variable substitution." + return + } + + if (!manifestVariablesFilesExist && manifestVariablesFilesExplicitlySpecified) { + // If the user explicitly specified a list of variables files, make sure they all exist. + // Otherwise throw an error so the user knows that he / she made a mistake. + error "[CFManifestSubstituteVariables] Could not find all given manifest variable substitution files. Make sure all files given as manifestVariablesFiles exist." + } + + def result + ExecutionContext context = new ExecutionContext() + + if (!manifestVariablesFilesExist && !manifestVariablesFilesExplicitlySpecified) { + // If no variables files exist (not even the default one) we check if at least we have a list of variables. + + if (!manifestVariablesListSpecified) { + // If we have no variable values to replace references with, we skip substitution. + echo "[CFManifestSubstituteVariables] Could not find any default manifest variable substitution file at ${defaultManifestVariablesFileName}, and no manifest variables list was specified. Skipping variable substitution." + return + } + + // If we have a list of variables specified, we can start replacing them... + result = substitute(manifestFilePath, [], manifestVariablesList, yamlUtils, context, debugHelper) + } + else { + // If we have at least one existing variable substitution file, we can start replacing variables... + result = substitute(manifestFilePath, manifestVariablesFiles, manifestVariablesList, yamlUtils, context, debugHelper) + } + + if (!context.variablesReplaced) { + // If no variables have been replaced at all, we skip writing a file. + echo "[CFManifestSubstituteVariables] No variables were found or could be replaced in ${manifestFilePath}. Skipping variable substitution." + return + } + + // writeYaml won't overwrite the file. You need to delete it first. + deleteFile(outputFilePath) + + writeYaml file: outputFilePath, data: result + + echo "[CFManifestSubstituteVariables] Replaced variables in ${manifestFilePath}." + echo "[CFManifestSubstituteVariables] Wrote output file (with variables replaced) at ${outputFilePath}." + } +} + +/* + * Substitutes variables specified in files and as lists in a given manifest file. + * @param manifestFilePath - the path to the manifest file to replace variables in. + * @param manifestVariablesFiles - the paths to variables substitution files. + * @param manifestVariablesList - the list of variables data to replace variables with. + * @param yamlUtils - the `YamlUtils` used for variable substitution. + * @param context - an `ExecutionContext` to examine if any variables have been replaced and should be written. + * @param debugHelper - a debug output helper. + * @return an Object graph of Yaml data with variables substituted (if any were found and could be replaced). + */ +private Object substitute(String manifestFilePath, List manifestVariablesFiles, List> manifestVariablesList, YamlUtils yamlUtils, ExecutionContext context, DebugHelper debugHelper) { + Boolean noVariablesReplaced = true + + def manifestData = loadManifestData(manifestFilePath, debugHelper) + + // replace variables from list first. + List> reversedManifestVariablesList = manifestVariablesList.reverse() // to make sure last one wins. + + def result = manifestData + for (Map manifestVariableData : reversedManifestVariablesList) { + def executionContext = new ExecutionContext() + result = yamlUtils.substituteVariables(result, manifestVariableData, executionContext) + noVariablesReplaced = noVariablesReplaced && !executionContext.variablesReplaced // remember if variables were replaced. + } + + // replace remaining variables from files + List reversedManifestVariablesFilesList = manifestVariablesFiles.reverse() // to make sure last one wins. + for (String manifestVariablesFilePath : reversedManifestVariablesFilesList) { + def manifestVariablesFileData = loadManifestVariableFileData(manifestVariablesFilePath, debugHelper) + def executionContext = new ExecutionContext() + result = yamlUtils.substituteVariables(result, manifestVariablesFileData, executionContext) + noVariablesReplaced = noVariablesReplaced && !executionContext.variablesReplaced // remember if variables were replaced. + } + + context.variablesReplaced = !noVariablesReplaced + return result +} + +/* + * Loads the contents of a manifest.yml file by parsing Yaml and returning the + * object graph. May return a `List` (in case more YAML segments are in the file) + * or a `Map` in case there is just one segment. + * @param manifestFilePath - the file path of the manifest to parse. + * @param debugHelper - a debug output helper. + * @return the parsed object graph. + */ +private Object loadManifestData(String manifestFilePath, DebugHelper debugHelper) { + try { + // may return a List (in case more YAML segments are in the file) + // or a Map in case there is just one segment. + def result = readYaml file: manifestFilePath + echo "[CFManifestSubstituteVariables] Loaded manifest at ${manifestFilePath}!" + return result + } + catch(Exception ex) { + debugHelper.debug("Exception: ${ex}") + echo "[CFManifestSubstituteVariables] Could not load manifest file at ${manifestFilePath}. Exception was: ${ex}" + throw ex + } +} + +/* + * Loads the contents of a manifest variables file by parsing Yaml and returning the + * object graph. May return a `List` (in case more YAML segments are in the file) + * or a `Map` in case there is just one segment. + * @param variablesFilePath - the path to the variables file to parse. + * @param debugHelper - a debug output helper. + * @return the parsed object graph. + */ +private Object loadManifestVariableFileData(String variablesFilePath, DebugHelper debugHelper) { + try { + // may return a List (in case more YAML segments are in the file) + // or a Map in case there is just one segment. + def result = readYaml file: variablesFilePath + echo "[CFManifestSubstituteVariables] Loaded variables file at ${variablesFilePath}!" + return result + } + catch(Exception ex) { + debugHelper.debug("Exception: ${ex}") + echo "[CFManifestSubstituteVariables] Could not load manifest variables file at ${variablesFilePath}. Exception was: ${ex}" + throw ex + } +} + +/* + * Checks if all file paths given in the list exist as files. + * @param manifestVariablesFiles - the list of file paths pointing to manifest variables files. + * @return `true`, if all given files exist, `false` otherwise. + */ +private boolean allManifestVariableFilesExist(List manifestVariablesFiles) { + for (String filePath : manifestVariablesFiles) { + Boolean fileExists = fileExists filePath + if (!fileExists) { + echo "[CFManifestSubstituteVariables] Did not find manifest variable substitution file at ${filePath}." + return false + } + } + return true +} + +/* + * Removes the given file, if it exists. + * @param filePath - the path to the file to remove. + */ +private void deleteFile(String filePath) { + + Boolean fileExists = fileExists file: filePath + if(fileExists) { + Boolean failure = sh script: "rm '${filePath}'", returnStatus: true + if(!failure) { + echo "[CFManifestSubstituteVariables] Successfully deleted file '${filePath}'." + } + else { + error "[CFManifestSubstituteVariables] Could not delete file '${filePath}'. Check file permissions." + } + } +} From 6f8cd0f88c001c707f99c10a01c7706a50b892a0 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 9 Sep 2019 09:34:21 +0200 Subject: [PATCH 041/141] more output from generator (#868) --- documentation/bin/createDocu.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/bin/createDocu.groovy b/documentation/bin/createDocu.groovy index f379afdca..d7c81d325 100644 --- a/documentation/bin/createDocu.groovy +++ b/documentation/bin/createDocu.groovy @@ -364,7 +364,7 @@ class Helper { def param = retrieveParameterName(line) if(!param) { - throw new RuntimeException('Cannot retrieve parameter for a comment') + throw new RuntimeException("Cannot retrieve parameter for a comment. Affected line was: '${line}'") } def _docu = [], _value = [], _mandatory = [], _parentObject = [] From f199d79ca96b239aa94e8eb05faede5cdbe5d899 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 9 Sep 2019 10:16:02 +0200 Subject: [PATCH 042/141] replace absolute links inside docu with relative links for ui5-sapcp scenario (#867) --- documentation/docs/index.md | 7 +++---- documentation/docs/scenarios/CAP_Scenario.md | 4 ++-- documentation/docs/scenarios/changeManagement.md | 14 +++++++------- documentation/docs/scenarios/ui5-sap-cp/Readme.md | 6 +++--- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 9bf30f9ca..96b83c976 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -15,7 +15,7 @@ Project "Piper" consists of two parts: * A set of [Docker images][devops-docker-images] used in the piper library to implement best practices. The shared library contains all the necessary steps to run our best practice -[Jenkins pipelines][piper-library-pages] described in the Scenarios section or +Jenkins pipelines described in the Scenarios section or to run a [pipeline as step][piper-library-scenario]. The best practice pipelines are based on the general concepts of [Jenkins 2.0 @@ -54,9 +54,8 @@ methods/types needs to be announced, discussed and agreed. [devops-docker-images]: https://github.com/SAP/devops-docker-images [devops-docker-images-issues]: https://github.com/SAP/devops-docker-images/issues [devops-docker-images-cxs-guide]: https://github.com/SAP/devops-docker-images/blob/master/docs/operations/cx-server-operations-guide.md -[piper-library-scenario]: https://sap.github.io/jenkins-library/scenarios/ui5-sap-cp/Readme/ -[piper-library-pages]: https://sap.github.io/jenkins-library -[piper-library-pages-plugins]: https://sap.github.io/jenkins-library/jenkins/requiredPlugins +[piper-library-scenario]: scenarios/ui5-sap-cp/Readme/ +[piper-library-pages-plugins]: requiredPlugins [piper-library-issues]: https://github.com/SAP/jenkins-library/issues [piper-library-license]: ./LICENSE [piper-library-contribution]: .github/CONTRIBUTING.md diff --git a/documentation/docs/scenarios/CAP_Scenario.md b/documentation/docs/scenarios/CAP_Scenario.md index 22cd3936f..b597fef99 100644 --- a/documentation/docs/scenarios/CAP_Scenario.md +++ b/documentation/docs/scenarios/CAP_Scenario.md @@ -61,5 +61,5 @@ steps: For the detailed description of the relevant parameters, see: -* [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) -* [cloudFoundryDeploy](https://sap.github.io/jenkins-library/steps/cloudFoundryDeploy/) +* [mtaBuild](../../../steps/mtaBuild/) +* [cloudFoundryDeploy](../../../steps/cloudFoundryDeploy/) diff --git a/documentation/docs/scenarios/changeManagement.md b/documentation/docs/scenarios/changeManagement.md index 11500ae76..1d76433e4 100644 --- a/documentation/docs/scenarios/changeManagement.md +++ b/documentation/docs/scenarios/changeManagement.md @@ -22,7 +22,7 @@ In this scenario, we want to show how an agile development process with Jenkins The basic workflow is as follows: -1. The pipeline scans the Git commit messages for a line like `ChangeDocument : `, and validates that the change is in the correct status `in development`. For more information, see [checkChangeInDevelopment](https://sap.github.io/jenkins-library/steps/checkChangeInDevelopment/). An example for the commit message looks as follows: +1. The pipeline scans the Git commit messages for a line like `ChangeDocument : `, and validates that the change is in the correct status `in development`. For more information, see [checkChangeInDevelopment](../../steps/checkChangeInDevelopment/). An example for the commit message looks as follows: ``` Fix terminology in documentation @@ -33,7 +33,7 @@ The basic workflow is as follows: **Note:** The blank line between message header and message description is mandatory. -1. To communicate with SAP Solution Manager, the pipeline uses credentials that must be stored on Jenkins using the credential ID `CM`. For more information, see [checkChangeInDevelopment](https://sap.github.io/jenkins-library/steps/checkChangeInDevelopment/). +1. To communicate with SAP Solution Manager, the pipeline uses credentials that must be stored on Jenkins using the credential ID `CM`. For more information, see [checkChangeInDevelopment](../../steps/checkChangeInDevelopment/). 1. The required transport request is created on the fly. **Note:** The change document can contain various components (for example, UI and backend components). 1. The changes of your development team trigger the Jenkins pipeline. It builds and validates the changes and attaches them to the respective transport request. 1. As soon as the development process is completed, the change document in SAP Solution Manager can be set to status `to be tested` and all components can be transported to the test system. @@ -91,8 +91,8 @@ steps: For the detailed description of the relevant parameters, see: -* [checkChangeInDevelopment](https://sap.github.io/jenkins-library/steps/checkChangeInDevelopment/) -* [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) -* [transportRequestCreate](https://sap.github.io/jenkins-library/steps/transportRequestCreate/) -* [transportRequestUploadFile](https://sap.github.io/jenkins-library/steps/transportRequestUploadFile/) -* [transportRequestRelease](https://sap.github.io/jenkins-library/steps/transportRequestRelease/) +* [checkChangeInDevelopment](../../steps/checkChangeInDevelopment/) +* [mtaBuild](../../steps/mtaBuild/) +* [transportRequestCreate](../../steps/transportRequestCreate/) +* [transportRequestUploadFile](../../steps/transportRequestUploadFile/) +* [transportRequestRelease](../../steps/transportRequestRelease/) diff --git a/documentation/docs/scenarios/ui5-sap-cp/Readme.md b/documentation/docs/scenarios/ui5-sap-cp/Readme.md index 0ad0201f1..a19acefd7 100644 --- a/documentation/docs/scenarios/ui5-sap-cp/Readme.md +++ b/documentation/docs/scenarios/ui5-sap-cp/Readme.md @@ -28,7 +28,7 @@ On the project level, provide and adjust the following template: This scenario combines various different steps to create a complete pipeline. -In this scenario, we want to show how to build an application based on SAPUI5 or SAP Fiori by using the multi-target application (MTA) concept and how to deploy the build result into an SAP Cloud Platform account in the Neo environment. This document comprises the [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) and the [neoDeploy](https://sap.github.io/jenkins-library/steps/neoDeploy/) steps. +In this scenario, we want to show how to build an application based on SAPUI5 or SAP Fiori by using the multi-target application (MTA) concept and how to deploy the build result into an SAP Cloud Platform account in the Neo environment. This document comprises the [mtaBuild](../../../steps/mtaBuild/) and the [neoDeploy](../../../steps/neoDeploy/) steps. ![This pipeline in Jenkins Blue Ocean](images/pipeline.jpg) ###### Screenshot: Build and Deploy Process in Jenkins @@ -82,5 +82,5 @@ steps: For the detailed description of the relevant parameters, see: -* [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) -* [neoDeploy](https://sap.github.io/jenkins-library/steps/neoDeploy/) +* [mtaBuild](../../../steps/mtaBuild/) +* [neoDeploy](../../../steps/neoDeploy/) From e54f18e6bce1ae3a230a748aaaadc74209bb1417 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 11 Sep 2019 13:42:38 +0200 Subject: [PATCH 043/141] Introduce xsDeploy step (#749) Introduce xs deploy --- documentation/docs/steps/xsDeploy.md | 41 ++ documentation/mkdocs.yml | 1 + resources/default_pipeline_environment.yml | 11 + test/groovy/XsDeployTest.groovy | 480 +++++++++++++++++++++ test/groovy/util/CommandLineMatcher.groovy | 2 +- vars/commonPipelineEnvironment.groovy | 2 + vars/xsDeploy.groovy | 338 +++++++++++++++ 7 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 documentation/docs/steps/xsDeploy.md create mode 100644 test/groovy/XsDeployTest.groovy create mode 100644 vars/xsDeploy.groovy diff --git a/documentation/docs/steps/xsDeploy.md b/documentation/docs/steps/xsDeploy.md new file mode 100644 index 000000000..0e3c8b4a9 --- /dev/null +++ b/documentation/docs/steps/xsDeploy.md @@ -0,0 +1,41 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} + +## Side effects + +none + +## Example + +```groovy +xsDeploy + script: this, + mtaPath: 'path/to/archiveFile.mtar', + credentialsId: 'my-credentials-id', + apiUrl: 'https://example.org/xs', + space: 'mySpace', + org:: 'myOrg' +``` + +Example configuration: + +```yaml +steps: + <...> + xsDeploy: + script: this, + mtaPath: path/to/archiveFile.mtar + credentialsId: my-credentials-id + apiUrl: https://example.org/xs + space: mySpace + org:: myOrg +``` + +[dockerExecute]: ../dockerExecute diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 904aedcce..fe004bf91 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -52,6 +52,7 @@ nav: - transportRequestUploadFile: steps/transportRequestUploadFile.md - uiVeri5ExecuteTests: steps/uiVeri5ExecuteTests.md - whitesourceExecuteScan: steps/whitesourceExecuteScan.md + - xsDeploy: steps/xsDeploy.md - 'Pipelines': - 'General purpose pipeline': - 'Introduction': stages/introduction.md diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index f12bdd7fd..7a221b7f2 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -575,3 +575,14 @@ steps: nodeLabel: '' stashContent: - 'pipelineConfigAndTests' + xsDeploy: + credentialsId: 'XS' + deployIdLogPattern: '^.*"xs bg-deploy -i (.*) -a .*".*$' + loginOpts: '' + deployOpts: '' + docker: + dockerImage: '' + dockerPullImage: false + mode: 'DEPLOY' + action: 'NONE' + xsSessionFile: '.xsconfig' diff --git a/test/groovy/XsDeployTest.groovy b/test/groovy/XsDeployTest.groovy new file mode 100644 index 000000000..ff5fdf608 --- /dev/null +++ b/test/groovy/XsDeployTest.groovy @@ -0,0 +1,480 @@ +import static org.junit.Assert.assertThat + +import org.hamcrest.Matchers + +import static org.hamcrest.Matchers.allOf +import static org.hamcrest.Matchers.contains +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.hasSize +import static org.hamcrest.Matchers.is + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.rules.RuleChain + +import util.BasePiperTest +import util.CommandLineMatcher +import util.JenkinsCredentialsRule +import util.JenkinsDockerExecuteRule +import util.JenkinsFileExistsRule +import util.JenkinsLockRule +import util.JenkinsLoggingRule +import util.JenkinsReadYamlRule +import util.JenkinsShellCallRule +import util.JenkinsStepRule +import util.Rules + +import com.sap.piper.JenkinsUtils + +import hudson.AbortException + +class XsDeployTest extends BasePiperTest { + + private ExpectedException thrown = ExpectedException.none() + + private List existingFiles = [ + '.xsconfig', + 'myApp.mta' + ] + + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsLockRule lockRule = new JenkinsLockRule(this) + private JenkinsLoggingRule logRule = new JenkinsLoggingRule(this) + + @Rule + public RuleChain ruleChain = Rules.getCommonRules(this) + .around(new JenkinsReadYamlRule(this)) + .around(stepRule) + .around(new JenkinsDockerExecuteRule(this)) + .around(new JenkinsCredentialsRule(this) + .withCredentials('myCreds', 'cred_xs', 'topSecret')) + .around(new JenkinsFileExistsRule(this, existingFiles)) + .around(lockRule) + .around(shellRule) + .around(logRule) + .around(thrown) + + @Test + public void testSanityChecks() { + + thrown.expect(IllegalArgumentException) + thrown.expectMessage( + allOf( + containsString('ERROR - NO VALUE AVAILABLE FOR:'), + containsString('apiUrl'), + containsString('org'), + containsString('space'), + containsString('mtaPath'))) + + stepRule.step.xsDeploy(script: nullScript) + } + + @Test + public void testLoginFailed() { + + thrown.expect(AbortException) + thrown.expectMessage('xs login failed') + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, '#!/bin/bash xs login .*', 1) + + try { + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mtaPath: 'myApp.mta' + ) + } catch(AbortException e ) { + + assertThat(shellRule.shell, + allOf( + // first item: the login attempt + // second item: we try to provide the logs + hasSize(2), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs login'), + new CommandLineMatcher() + .hasProlog('LOG_FOLDER') + .hasSnippet('cat \\$\\{LOG_FOLDER\\}/\\*') + ) + ) + throw e + } + } + + @Test + public void testDeployFailed() { + + thrown.expect(AbortException) + thrown.expectMessage('Failed command(s): [xs deploy]. Check earlier log for details.') + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, '#!/bin/bash.*xs deploy .*', {throw new AbortException()}) + + try { + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mtaPath: 'myApp.mta' + ) + } catch(AbortException e ) { + + assertThat(shellRule.shell, + allOf( + hasSize(4), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs login'), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs deploy'), + new CommandLineMatcher() + .hasProlog('#!/bin/bash') + .hasSnippet('xs logout'), // logout must be present in case deployment failed. + new CommandLineMatcher() + .hasProlog('') + .hasSnippet('rm \\$\\{XSCONFIG\\}') // remove the session file after logout + ) + ) + throw e + } + } + + @Test + public void testNothingHappensWhenModeIsNone() { + + stepRule.step.xsDeploy( + script: nullScript, + mode: 'NONE' + ) + + assertThat(logRule.log, containsString('Deployment skipped intentionally.')) + assertThat(shellRule.shell, hasSize(0)) + } + + @Test + public void testDeploymentFailsWhenDeployableIsNotPresent() { + + thrown.expect(AbortException) + thrown.expectMessage('Deployable \'myApp.mta\' does not exist.') + + existingFiles.remove('myApp.mta') + + try { + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mtaPath: 'myApp.mta' + ) + } catch(AbortException e) { + + // no shell operation happened in this case. + assertThat(shellRule.shell.size(), is(0)) + + throw e + } + } + + @Test + public void testDeployStraighForward() { + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + deployOpts: '-t 60', + mtaPath: 'myApp.mta' + ) + + assertThat(shellRule.shell, + allOf( + new CommandLineMatcher() + .hasProlog("#!/bin/bash xs login") + .hasSnippet('xs login') + .hasOption('a', 'https://example.org/xs') + .hasOption('u', 'cred_xs') + .hasSingleQuotedOption('p', 'topSecret') + .hasOption('o', 'myOrg') + .hasOption('s', 'mySpace'), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs deploy') + .hasOption('t', '60') + .hasArgument('\'myApp.mta\''), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs logout') + ) + ) + + assertThat(lockRule.getLockResources(), contains('xsDeploy:https://example.org/xs:myOrg:mySpace')) + + } + + @Test + public void testInvalidDeploymentModeProviced() { + + thrown.expect(IllegalArgumentException) + thrown.expectMessage('No enum constant') + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + deployOpts: '-t 60', + mtaPath: 'myApp.mta', + mode: 'DOES_NOT_EXIST' + ) + } + + @Test + public void testActionProvidedForStandardDeployment() { + + thrown.expect(AbortException) + thrown.expectMessage( + 'Cannot perform action \'resume\' in mode \'deploy\'. Only action \'none\' is allowed.') + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + deployOpts: '-t 60', + mtaPath: 'myApp.mta', + mode: 'DEPLOY', // this is the default anyway + action: 'RESUME' + ) + } + + @Test + public void testBlueGreenDeployFailes() { + + thrown.expect(AbortException) + thrown.expectMessage('Failed command(s): [xs bg-deploy]') + + logRule.expect('Something went wrong') + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, '#!/bin/bash.*xs bg-deploy .*', + { throw new AbortException('Something went wrong.') }) + + try { + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mtaPath: 'myApp.mta', + mode: 'BG_DEPLOY' + ) + } catch(AbortException e) { + + // in case there is a deployment failure we have to logout also for bg-deployments + assertThat(shellRule.shell, + new CommandLineMatcher() + .hasProlog('#!/bin/bash') + .hasSnippet('xs logout') + ) + + throw e + } +} + + @Test + public void testBlueGreenDeployStraighForward() { + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, '#!/bin/bash.*xs bg-deploy .*', + ((CharSequence)''' | + | + |Uploading 1 files: + |/myFolder/my.mtar + |File upload finished + | + |Detected MTA schema version: "3.1.0" + |Detected deploy target as "myOrg mySpace" + |Detected deployed MTA with ID "my_mta" and version "0.0.1" + |Deployed MTA color: blue + |New MTA color: green + |Detected new MTA version: "0.0.1" + |Deployed MTA version: 0.0.1 + |Service "xxx" is not modified and will not be updated + |Creating application "db-green" from MTA module "xx"... + |Uploading application "xx-green"... + |Staging application "xx-green"... + |Application "xx-green" staged + |Executing task "deploy" on application "xx-green"... + |Task execution status: succeeded + |Process has entered validation phase. After testing your new deployment you can resume or abort the process. + |Use "xs bg-deploy -i 1234 -a resume" to resume the process. + |Use "xs bg-deploy -i 1234 -a abort" to abort the process. + |Hint: Use the '--no-confirm' option of the bg-deploy command to skip this phase. + |''').stripMargin()) + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + deployOpts: '-t 60', + mtaPath: 'myApp.mta', + mode: 'BG_DEPLOY' + ) + + assertThat(nullScript.commonPipelineEnvironment.xsDeploymentId, is('1234')) + + assertThat(shellRule.shell, + allOf( + new CommandLineMatcher() + .hasProlog("#!/bin/bash xs login") + .hasOption('a', 'https://example.org/xs') + .hasOption('u', 'cred_xs') + .hasSingleQuotedOption('p', 'topSecret') + .hasOption('o', 'myOrg') + .hasOption('s', 'mySpace'), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasOption('t', '60') + .hasArgument('\'myApp.mta\''), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + ) + ) + + assertThat(lockRule.getLockResources(), contains('xsDeploy:https://example.org/xs:myOrg:mySpace')) + } + + @Test + public void testBlueGreenDeployResumeWithoutDeploymentId() { + + // this happens in case we would like to complete a deployment without having a (successful) deployments before. + + thrown.expect(IllegalArgumentException) + thrown.expectMessage( + allOf( + containsString('No deployment id provided'), + containsString('Was there a deployment before?'))) + + nullScript.commonPipelineEnvironment.xsDeploymentId = null // is null anyway, just for clarification + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mode: 'BG_DEPLOY', + action: 'RESUME' + ) + } + + @Test + public void testBlueGreenDeployWithoutExistingSession() { + + thrown.expect(AbortException) + thrown.expectMessage( + 'For the current configuration an already existing session is required.' + + ' But there is no already existing session') + + existingFiles.remove('.xsconfig') + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mode: 'BG_DEPLOY', + action: 'RESUME' + ) + + } + + @Test + public void testBlueGreenDeployResumeFails() { + + // e.g. we try to resume a deployment which did not succeed or which was already resumed or aborted. + + thrown.expect(AbortException) + thrown.expectMessage('Failed command(s): [xs bg-deploy -a resume].') + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, 'xs bg-deploy -i .*', 1) + + nullScript.commonPipelineEnvironment.xsDeploymentId = '1234' + + try { + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mode: 'BG_DEPLOY', + action: 'RESUME' + ) + } catch(AbortException e) { + + // logout must happen also in case of a failed deployment + assertThat(shellRule.shell, + new CommandLineMatcher() + .hasProlog('') + .hasSnippet('xs logout')) + throw e + } + } + + @Test + public void testBlueGreenDeployResume() { + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, 'xs bg-deploy -i .*', 0) + + nullScript.commonPipelineEnvironment.xsDeploymentId = '1234' + + stepRule.step.xsDeploy( + script: nullScript, + apiUrl: 'https://example.org/xs', + org: 'myOrg', + space: 'mySpace', + credentialsId: 'myCreds', + mode: 'BG_DEPLOY', + action: 'RESUME' + ) + + // there is no login in case of a resume since we have to use the old session which triggered the deployment. + assertThat(shellRule.shell, + allOf( + hasSize(3), + new CommandLineMatcher() + .hasProlog('#!/bin/bash') + .hasSnippet('xs bg-deploy') + .hasOption('i', '1234') + .hasOption('a', 'resume'), + new CommandLineMatcher() + .hasProlog("#!/bin/bash") + .hasSnippet('xs logout'), + new CommandLineMatcher() + .hasProlog('') + .hasSnippet('rm \\$\\{XSCONFIG\\}') // delete the session file after logout + ) + ) + + assertThat(lockRule.getLockResources(), contains('xsDeploy:https://example.org/xs:myOrg:mySpace')) + + } + +} \ No newline at end of file diff --git a/test/groovy/util/CommandLineMatcher.groovy b/test/groovy/util/CommandLineMatcher.groovy index 734a5acc0..554017334 100644 --- a/test/groovy/util/CommandLineMatcher.groovy +++ b/test/groovy/util/CommandLineMatcher.groovy @@ -55,7 +55,7 @@ class CommandLineMatcher extends BaseMatcher { } for (MapEntry opt : opts) { - if (!cmd.matches(/.*[\s]*--${opt.key}[\s]*${opt.value}.*/)) { + if (!cmd.matches(/.*[\s]*-${opt.key}[\s]*${opt.value}.*/)) { hint = "A command line containing option \'${opt.key}\' with value \'${opt.value}\'" matches = false } diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index 98d9db24b..a0ec24150 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -17,6 +17,8 @@ class commonPipelineEnvironment implements Serializable { String gitHttpsUrl String gitBranch + String xsDeploymentId + //GiutHub specific information String githubOrg String githubRepo diff --git a/vars/xsDeploy.groovy b/vars/xsDeploy.groovy new file mode 100644 index 000000000..d98033c1b --- /dev/null +++ b/vars/xsDeploy.groovy @@ -0,0 +1,338 @@ +import com.sap.piper.JenkinsUtils + +import static com.sap.piper.Prerequisites.checkScript + +import com.sap.piper.BashUtils +import com.sap.piper.ConfigurationHelper +import com.sap.piper.GenerateDocumentation +import com.sap.piper.Utils + +import groovy.transform.Field + +import hudson.AbortException + +@Field String STEP_NAME = getClass().getName() + +@Field Set GENERAL_CONFIG_KEYS = STEP_CONFIG_KEYS + +@Field Set STEP_CONFIG_KEYS = [ + 'action', + 'apiUrl', + 'credentialsId', + 'deploymentId', + 'deployIdLogPattern', + 'deployOpts', + /** A map containing properties forwarded to dockerExecute. For more details see [here][dockerExecute] */ + 'docker', + 'loginOpts', + 'mode', + 'mtaPath', + 'org', + 'space', + 'xsSessionFile', +] + +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +enum DeployMode { + DEPLOY, + BG_DEPLOY, + NONE + + String toString() { + name().toLowerCase(Locale.ENGLISH).replaceAll('_', '-') + } +} + +enum Action { + RESUME, + ABORT, + RETRY, + NONE + + String toString() { + name().toLowerCase(Locale.ENGLISH) + } +} + +/** + * Performs an XS deployment + * + * In case of blue-green deployments the step is called for the deployment in the narrower sense + * and later again for resuming or aborting. In this case both calls needs to be performed from the + * same directory. + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { + + def utils = parameters.juStabUtils ?: new Utils() + + final script = checkScript(this, parameters) ?: this + + ConfigurationHelper configHelper = ConfigurationHelper.newInstance(this) + .loadStepDefaults() + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS) + .addIfEmpty('mtaPath', script.commonPipelineEnvironment.getMtarFilePath()) + .addIfEmpty('deploymentId', script.commonPipelineEnvironment.xsDeploymentId) + .mixin(parameters, PARAMETER_KEYS) + + Map config = configHelper.use() + + DeployMode mode = config.mode + + if(mode == DeployMode.NONE) { + echo "Deployment skipped intentionally. Deploy mode '${mode.toString()}'." + return + } + + Action action = config.action + + if(mode == DeployMode.DEPLOY && action != Action.NONE) { + error "Cannot perform action '${action.toString()}' in mode '${mode.toString()}'. Only action '${Action.NONE.toString()}' is allowed." + } + + boolean performLogin = ((mode == DeployMode.DEPLOY) || (mode == DeployMode.BG_DEPLOY && !(action in [Action.RESUME, Action.ABORT]))) + boolean performLogout = ((mode == DeployMode.DEPLOY) || (mode == DeployMode.BG_DEPLOY && action != Action.NONE)) + + boolean sessionExists = fileExists file: config.xsSessionFile + + if( (! performLogin) && (! sessionExists) ) { + error 'For the current configuration an already existing session is required. But there is no already existing session.' + } + + configHelper + .collectValidationFailures() + /** + * Used for finalizing the blue-green deployment. + * @possibleValues RESUME, ABORT, RETRY + */ + .withMandatoryProperty('action') + /** The file name of the file representing the sesssion after `xs login`. Should not be changed normally. */ + .withMandatoryProperty('xsSessionFile') + /** Regex pattern for retrieving the ID of the deployment. */ + .withMandatoryProperty('deployIdLogPattern') + /** + * Controls if there is a standard deployment or a blue green deployment + * @possibleValues DEPLOY, BG_DEPLOY + */ + .withMandatoryProperty('mode') + /** The endpoint */ + .withMandatoryProperty('apiUrl') + /** The organization */ + .withMandatoryProperty('org') + /** The space */ + .withMandatoryProperty('space') + /** Additional options appended to the login command. Only needed for sophisticated cases. + * When provided it is the duty of the provider to ensure proper quoting / escaping. + */ + .withMandatoryProperty('loginOpts') + /** Additional options appended to the deploy command. Only needed for sophisticated cases. + * When provided it is the duty of the provider to ensure proper quoting / escaping. + */ + .withMandatoryProperty('deployOpts') + /** The credentialsId */ + .withMandatoryProperty('credentialsId') + /** The path to the deployable. If not provided explicitly it is retrieved from the common pipeline environment + * (Parameter `mtarFilePath`). + */ + .withMandatoryProperty('mtaPath', null, {action == Action.NONE}) + .withMandatoryProperty('deploymentId', + 'No deployment id provided, neither via parameters nor via common pipeline environment. Was there a deployment before?', + {action in [Action.RESUME, Action.ABORT, Action.RETRY]}) + .use() + + utils.pushToSWA([ + step: STEP_NAME, + ], config) + + if(action == Action.NONE) { + boolean deployableExists = fileExists file: config.mtaPath + if(! deployableExists) + error "Deployable '${config.mtaPath}' does not exist." + } + + if(performLogin) { + login(script, config) + } + + def failures = [] + + if(action in [Action.RESUME, Action.ABORT, Action.RETRY]) { + + complete(script, mode, action, config, failures) + + } else { + + deploy(script, mode, config, failures) + } + + if (performLogout || failures) { + logout(script, config, failures) + + } else { + echo "Skipping logout in order to be able to resume or abort later." + } + + if(failures) { + error "Failed command(s): ${failures}. Check earlier log for details." + } + } +} + +void login(Script script, Map config) { + + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + passwordVariable: 'password', + usernameVariable: 'username' + )]) { + + def returnCode = executeXSCommand([script: script].plus(config.docker), + [ + "xs login -a ${config.apiUrl} -u ${username} -p ${BashUtils.quoteAndEscape(password)} -o ${config.org} -s ${config.space} ${config.loginOpts}", + 'RC=$?', + "[ \$RC == 0 ] && cp \"\${HOME}/${config.xsSessionFile}\" .", + 'exit $RC' + ]) + + if(returnCode != 0) + error "xs login failed." + } + + boolean existsXsSessionFileAfterLogin = fileExists file: config.xsSessionFile + if(! existsXsSessionFileAfterLogin) + error "Session file ${config.xsSessionFile} not found in current working directory after login." +} + +void deploy(Script script, DeployMode mode, Map config, def failures) { + + def deploymentLog + + try { + lock(getLockIdentifier(config)) { + deploymentLog = executeXSCommand([script: script].plus(config.docker), + [ + "cp ${config.xsSessionFile} \${HOME}", + "xs ${mode.toString()} '${config.mtaPath}' -f ${config.deployOpts}" + ], true) + } + + echo "Deploy log: ${deploymentLog}" + + } catch(AbortException e) { + echo "deployment failed. Message: ${e.getMessage()}, Log: ${deploymentLog}}" + failures << "xs ${mode.toString()}" + } + + if(mode == DeployMode.BG_DEPLOY) { + + if(! failures.isEmpty()) { + + echo "Retrieval of deploymentId skipped since prior deployment was not successfull." + + } else { + + for (def logLine : deploymentLog.readLines()) { + def matcher = logLine =~ config.deployIdLogPattern + if(matcher.find()) { + script.commonPipelineEnvironment.xsDeploymentId = matcher[0][1] + echo "DeploymentId: ${script.commonPipelineEnvironment.xsDeploymentId}." + break + } + } + if(script.commonPipelineEnvironment.xsDeploymentId == null) { + failures << "Cannot lookup deploymentId. Search pattern was: '${config.deployIdLogPattern}'." + } + } + } +} + +void complete(Script script, DeployMode mode, Action action, Map config, def failures) { + + if(mode != DeployMode.BG_DEPLOY) + error "Action '${action.toString()}' can only be performed for mode '${DeployMode.BG_DEPLOY.toString()}'. Current mode is: '${mode.toString()}'." + + def returnCode = 1 + + lock(getLockIdentifier(config)) { + returnCode = executeXSCommand([script: script].plus(config.docker), + [ + "cp ${config.xsSessionFile} \${HOME}", + "xs ${mode.toString()} -i ${config.deploymentId} -a ${action.toString()}" + ]) + } + + if(returnCode != 0) { + echo "${mode.toString()} with action '${action.toString()}' failed with return code ${returnCode}." + failures << "xs ${mode.toString()} -a ${action.toString()}" + } +} + +void logout(Script script, Map config, def failures) { + + def returnCode = executeXSCommand([script: script].plus(config.docker), + [ + "cp ${config.xsSessionFile} \${HOME}", + 'xs logout' + ]) + + if(returnCode != 0) { + failures << 'xs logout' + } + + sh "XSCONFIG=${config.xsSessionFile}; [ -f \${XSCONFIG} ] && rm \${XSCONFIG}" +} + +String getLockIdentifier(Map config) { + "$STEP_NAME:${config.apiUrl}:${config.org}:${config.space}" +} + +def executeXSCommand(Map dockerOptions, List commands, boolean returnStdout = false) { + + def r + + dockerExecute(dockerOptions) { + + // in case there are credentials contained in the commands we assume + // the call is properly wrapped by withCredentials(./.) + echo "Executing: '${commands}'." + + List prelude = [ + '#!/bin/bash' + ] + + List script = (prelude + commands) + + params = [ + script: script.join('\n') + ] + + if(returnStdout) { + params << [ returnStdout: true ] + } else { + params << [ returnStatus: true ] + } + + r = sh params + + if( (! returnStdout ) && r != 0) { + + try { + echo "xs logs:" + + sh 'LOG_FOLDER=${HOME}/.xs_logs; [ -d ${LOG_FOLDER} ] && cat ${LOG_FOLDER}/*' + + } catch(Exception e) { + + echo "Cannot provide xs logs: ${e.getMessage()}." + } + + echo "Executing of commands '${commands}' failed. Check earlier logs for details." + } + } + r +} From 565ac99742197d25852a2be84f9eb2a1df3b101a Mon Sep 17 00:00:00 2001 From: Florian Geckeler <43751896+fgeckeler@users.noreply.github.com> Date: Thu, 12 Sep 2019 10:52:05 +0200 Subject: [PATCH 044/141] Handle sidecar parameters in dockerExecuteOnKubernetes (#869) --- src/com/sap/piper/SidecarUtils.groovy | 41 ++++++ .../DockerExecuteOnKubernetesTest.groovy | 32 ++++- test/groovy/DockerExecuteTest.groovy | 61 ++++----- vars/dockerExecute.groovy | 116 ++++++----------- vars/dockerExecuteOnKubernetes.groovy | 118 +++++++++++++----- 5 files changed, 227 insertions(+), 141 deletions(-) create mode 100644 src/com/sap/piper/SidecarUtils.groovy diff --git a/src/com/sap/piper/SidecarUtils.groovy b/src/com/sap/piper/SidecarUtils.groovy new file mode 100644 index 000000000..8c7eef99c --- /dev/null +++ b/src/com/sap/piper/SidecarUtils.groovy @@ -0,0 +1,41 @@ +package com.sap.piper + +class SidecarUtils implements Serializable { + + private static Script script + + SidecarUtils(Script script) { + this.script = script + } + + void waitForSidecarReadyOnDocker(String containerId, String command) { + String dockerCommand = "docker exec ${containerId} ${command}" + waitForSidecarReady(dockerCommand) + } + + void waitForSidecarReadyOnKubernetes(String containerName, String command) { + script.container(name: containerName) { + waitForSidecarReady(command) + } + } + + void waitForSidecarReady(String command) { + int sleepTimeInSeconds = 10 + int timeoutInSeconds = 5 * 60 + int maxRetries = timeoutInSeconds / sleepTimeInSeconds + int retries = 0 + while (true) { + script.echo "Waiting for sidecar container" + String status = script.sh script: command, returnStatus: true + if (status == "0") { + return + } + if (retries > maxRetries) { + script.error("Timeout while waiting for sidecar container to be ready") + } + + sleep sleepTimeInSeconds + retries++ + } + } +} diff --git a/test/groovy/DockerExecuteOnKubernetesTest.groovy b/test/groovy/DockerExecuteOnKubernetesTest.groovy index 970ef1016..383aac5dc 100644 --- a/test/groovy/DockerExecuteOnKubernetesTest.groovy +++ b/test/groovy/DockerExecuteOnKubernetesTest.groovy @@ -228,7 +228,7 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { } @Test - void testSidecarDefault() { + void testSidecarDefaultWithContainerMap() { List portMapping = [] helper.registerAllowedMethod('portMapping', [Map.class], {m -> portMapping.add(m) @@ -273,6 +273,36 @@ class DockerExecuteOnKubernetesTest extends BasePiperTest { assertThat(envList, hasItem(hasItem(allOf(hasEntry('name', 'customEnvKey'), hasEntry ('value','customEnvValue'))))) } + @Test + void testSidecarDefaultWithParameters() { + List portMapping = [] + helper.registerAllowedMethod('portMapping', [Map.class], {m -> + portMapping.add(m) + return m + }) + stepRule.step.dockerExecuteOnKubernetes( + script: nullScript, + juStabUtils: utils, + containerMap: ['maven:3.5-jdk-8-alpine': 'mavenexecute'], + containerName: 'mavenexecute', + dockerOptions: '-it', + dockerVolumeBind: ['my_vol': '/my_vol'], + dockerEnvVars: ['http_proxy': 'http://proxy:8000'], + dockerWorkspace: '/home/piper', + sidecarEnvVars: ['testEnv': 'testVal'], + sidecarImage: 'postgres', + sidecarName: 'postgres', + sidecarReadyCommand: 'pg_isready' + ) { + bodyExecuted = true + } + + assertThat(bodyExecuted, is(true)) + + assertThat(containersList, allOf(hasItem('postgres'), hasItem('mavenexecute'))) + assertThat(imageList, allOf(hasItem('maven:3.5-jdk-8-alpine'), hasItem('postgres'))) + } + @Test void testDockerExecuteOnKubernetesWithCustomShell() { stepRule.step.dockerExecuteOnKubernetes( diff --git a/test/groovy/DockerExecuteTest.groovy b/test/groovy/DockerExecuteTest.groovy index ce8a3e236..8f7935145 100644 --- a/test/groovy/DockerExecuteTest.groovy +++ b/test/groovy/DockerExecuteTest.groovy @@ -1,6 +1,6 @@ import com.sap.piper.k8s.ContainerMap import com.sap.piper.JenkinsUtils - +import com.sap.piper.SidecarUtils import org.junit.Before import org.junit.Rule import org.junit.Test @@ -41,7 +41,7 @@ class DockerExecuteTest extends BasePiperTest { void init() { bodyExecuted = false docker = new DockerMock() - JenkinsUtils.metaClass.static.isPluginActive = {def s -> new PluginMock(s).isActive()} + JenkinsUtils.metaClass.static.isPluginActive = { def s -> new PluginMock(s).isActive() } binding.setVariable('docker', docker) shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, "docker .*", 0) } @@ -53,7 +53,7 @@ class DockerExecuteTest extends BasePiperTest { containerName = container body() }) - helper.registerAllowedMethod('withEnv',[List.class, Closure.class], { List envVars, Closure body -> + helper.registerAllowedMethod('withEnv', [List.class, Closure.class], { List envVars, Closure body -> usedDockerEnvVars = envVars body() }) @@ -68,7 +68,7 @@ class DockerExecuteTest extends BasePiperTest { assertEquals('mavenexec', containerName) assertEquals(usedDockerEnvVars[0].toString(), "http_proxy=http://proxy:8000") assertTrue(bodyExecuted) - } + } @Test void testExecuteInsideNewlyCreatedPod() throws Exception { @@ -102,7 +102,7 @@ class DockerExecuteTest extends BasePiperTest { void testExecuteInsidePodWithStageKeyEmptyValue() throws Exception { helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> body() }) binding.setVariable('env', [POD_NAME: 'testpod', ON_K8S: 'true']) - ContainerMap.instance.setMap(['testpod':[:]]) + ContainerMap.instance.setMap(['testpod': [:]]) stepRule.step.dockerExecute(script: nullScript, dockerImage: 'maven:3.5-jdk-8-alpine', dockerEnvVars: ['http_proxy': 'http://proxy:8000']) { @@ -115,7 +115,7 @@ class DockerExecuteTest extends BasePiperTest { @Test void testExecuteInsidePodWithCustomCommandAndShell() throws Exception { Map kubernetesConfig = [:] - helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], {Map config, Closure body -> + helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { Map config, Closure body -> kubernetesConfig = config return body() }) @@ -125,7 +125,7 @@ class DockerExecuteTest extends BasePiperTest { containerCommand: '/busybox/tail -f /dev/null', containerShell: '/busybox/sh', dockerImage: 'maven:3.5-jdk-8-alpine' - ){ + ) { bodyExecuted = true } assertTrue(loggingRule.log.contains('Executing inside a Kubernetes Pod')) @@ -147,7 +147,7 @@ class DockerExecuteTest extends BasePiperTest { @Test void testSkipDockerImagePull() throws Exception { - nullScript.commonPipelineEnvironment.configuration = [steps:[dockerExecute:[dockerPullImage: false]]] + nullScript.commonPipelineEnvironment.configuration = [steps: [dockerExecute: [dockerPullImage: false]]] stepRule.step.dockerExecute( script: nullScript, dockerImage: 'maven:3.5-jdk-8-alpine' @@ -164,11 +164,11 @@ class DockerExecuteTest extends BasePiperTest { script: nullScript, dockerName: 'maven', dockerImage: 'maven:3.5-jdk-8-alpine', - sidecarEnvVars: ['testEnv':'testVal'], + sidecarEnvVars: ['testEnv': 'testVal'], sidecarImage: 'selenium/standalone-chrome', - sidecarVolumeBind: ['/dev/shm':'/dev/shm'], + sidecarVolumeBind: ['/dev/shm': '/dev/shm'], sidecarName: 'testAlias', - sidecarPorts: ['4444':'4444', '1111':'1111'], + sidecarPorts: ['4444': '4444', '1111': '1111'], sidecarPullImage: false ) { bodyExecuted = true @@ -180,10 +180,10 @@ class DockerExecuteTest extends BasePiperTest { @Test void testExecuteInsideDockerContainerWithParameters() throws Exception { stepRule.step.dockerExecute(script: nullScript, - dockerImage: 'maven:3.5-jdk-8-alpine', - dockerOptions: '-description=lorem ipsum', - dockerVolumeBind: ['my_vol': '/my_vol'], - dockerEnvVars: ['http_proxy': 'http://proxy:8000']) { + dockerImage: 'maven:3.5-jdk-8-alpine', + dockerOptions: '-description=lorem ipsum', + dockerVolumeBind: ['my_vol': '/my_vol'], + dockerEnvVars: ['http_proxy': 'http://proxy:8000']) { bodyExecuted = true } assertTrue(docker.getParameters().contains('--env https_proxy ')) @@ -221,16 +221,16 @@ class DockerExecuteTest extends BasePiperTest { } @Test - void testSidecarDefault(){ + void testSidecarDefault() { stepRule.step.dockerExecute( script: nullScript, dockerName: 'maven', dockerImage: 'maven:3.5-jdk-8-alpine', - sidecarEnvVars: ['testEnv':'testVal'], + sidecarEnvVars: ['testEnv': 'testVal'], sidecarImage: 'selenium/standalone-chrome', - sidecarVolumeBind: ['/dev/shm':'/dev/shm'], + sidecarVolumeBind: ['/dev/shm': '/dev/shm'], sidecarName: 'testAlias', - sidecarPorts: ['4444':'4444', '1111':'1111'] + sidecarPorts: ['4444': '4444', '1111': '1111'] ) { bodyExecuted = true } @@ -250,7 +250,7 @@ class DockerExecuteTest extends BasePiperTest { } @Test - void testSidecarHealthCheck(){ + void testSidecarHealthCheck() { stepRule.step.dockerExecute( script: nullScript, dockerImage: 'maven:3.5-jdk-8-alpine', @@ -262,18 +262,19 @@ class DockerExecuteTest extends BasePiperTest { } @Test - void testSidecarKubernetes(){ + void testSidecarKubernetes() { boolean dockerExecuteOnKubernetesCalled = false binding.setVariable('env', [ON_K8S: 'true']) helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { params, body -> dockerExecuteOnKubernetesCalled = true - assertThat(params.containerCommands['selenium/standalone-chrome'], is('')) - assertThat(params.containerEnvVars, allOf(hasEntry('selenium/standalone-chrome', ['testEnv': 'testVal']),hasEntry('maven:3.5-jdk-8-alpine', null))) - assertThat(params.containerMap, allOf(hasEntry('maven:3.5-jdk-8-alpine', 'maven'), hasEntry('selenium/standalone-chrome', 'selenium'))) + assertThat(params.dockerImage, is('maven:3.5-jdk-8-alpine')) + assertThat(params.containerName, is('maven')) + assertThat(params.sidecarEnvVars, is(['testEnv': 'testVal'])) + assertThat(params.sidecarName, is('selenium')) + assertThat(params.sidecarImage, is('selenium/standalone-chrome')) assertThat(params.containerName, is('maven')) assertThat(params.containerPortMappings['selenium/standalone-chrome'], hasItem(allOf(hasEntry('containerPort', 4444), hasEntry('hostPort', 4444)))) - assertThat(params.containerWorkspaces['maven:3.5-jdk-8-alpine'], is('/home/piper')) - assertThat(params.containerWorkspaces['selenium/standalone-chrome'], is('')) + assertThat(params.dockerWorkspace, is('/home/piper')) body() }) stepRule.step.dockerExecute( @@ -284,10 +285,10 @@ class DockerExecuteTest extends BasePiperTest { dockerImage: 'maven:3.5-jdk-8-alpine', dockerName: 'maven', dockerWorkspace: '/home/piper', - sidecarEnvVars: ['testEnv':'testVal'], + sidecarEnvVars: ['testEnv': 'testVal'], sidecarImage: 'selenium/standalone-chrome', sidecarName: 'selenium', - sidecarVolumeBind: ['/dev/shm':'/dev/shm'] + sidecarVolumeBind: ['/dev/shm': '/dev/shm'] ) { bodyExecuted = true } @@ -296,11 +297,13 @@ class DockerExecuteTest extends BasePiperTest { } @Test - void testSidecarKubernetesHealthCheck(){ + void testSidecarKubernetesHealthCheck() { binding.setVariable('env', [ON_K8S: 'true']) helper.registerAllowedMethod('dockerExecuteOnKubernetes', [Map.class, Closure.class], { params, body -> body() + SidecarUtils sidecarUtils = new SidecarUtils(nullScript) + sidecarUtils.waitForSidecarReadyOnKubernetes(params.sidecarName, params.sidecarReadyCommand) }) def containerCalled = false diff --git a/vars/dockerExecute.groovy b/vars/dockerExecute.groovy index a81c40ee4..fce2b9179 100644 --- a/vars/dockerExecute.groovy +++ b/vars/dockerExecute.groovy @@ -1,3 +1,5 @@ +import com.sap.piper.SidecarUtils + import static com.sap.piper.Prerequisites.checkScript import com.cloudbees.groovy.cps.NonCPS @@ -120,10 +122,12 @@ void call(Map parameters = [:], body) { .loadStepDefaults() .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) - .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName ?: env.STAGE_NAME, STEP_CONFIG_KEYS) .mixin(parameters, PARAMETER_KEYS) .use() + SidecarUtils sidecarUtils = new SidecarUtils(script) + new Utils().pushToSWA([ step: STEP_NAME, stepParamKey1: 'scriptMissing', @@ -146,9 +150,13 @@ void call(Map parameters = [:], body) { } } } else { + if (!config.dockerName) { + config.dockerName = UUID.randomUUID().toString() + } if (!config.sidecarImage) { dockerExecuteOnKubernetes( script: script, + containerName: config.dockerName, containerCommand: config.containerCommand, containerShell: config.containerShell, dockerImage: config.dockerImage, @@ -161,43 +169,24 @@ void call(Map parameters = [:], body) { body() } } else { - if(!config.dockerName){ - config.dockerName = UUID.randomUUID().toString() - } - - Map paramMap = [ + dockerExecuteOnKubernetes( script: script, - containerCommands: [:], - containerEnvVars: [:], - containerPullImageFlags: [:], - containerMap: [:], containerName: config.dockerName, - containerPortMappings: [:], - containerWorkspaces: [:], - stashContent: config.stashContent - ] - - paramMap.containerCommands[config.sidecarImage] = '' - - paramMap.containerEnvVars[config.dockerImage] = config.dockerEnvVars - paramMap.containerEnvVars[config.sidecarImage] = config.sidecarEnvVars - - paramMap.containerPullImageFlags[config.dockerImage] = config.dockerPullImage - paramMap.containerPullImageFlags[config.sidecarImage] = config.sidecarPullImage - - paramMap.containerMap[config.dockerImage] = config.dockerName - paramMap.containerMap[config.sidecarImage] = config.sidecarName - - paramMap.containerPortMappings = config.containerPortMappings - - paramMap.containerWorkspaces[config.dockerImage] = config.dockerWorkspace - paramMap.containerWorkspaces[config.sidecarImage] = '' - - dockerExecuteOnKubernetes(paramMap){ - echo "[INFO][${STEP_NAME}] Executing inside a Kubernetes Pod with sidecar container" - if(config.sidecarReadyCommand) { - waitForSidecarReadyOnKubernetes(config.sidecarName, config.sidecarReadyCommand) - } + containerCommand: config.containerCommand, + containerShell: config.containerShell, + dockerImage: config.dockerImage, + dockerPullImage: config.dockerPullImage, + dockerEnvVars: config.dockerEnvVars, + dockerWorkspace: config.dockerWorkspace, + stashContent: config.stashContent, + containerPortMappings: config.containerPortMappings, + sidecarName: parameters.sidecarName, + sidecarImage: parameters.sidecarImage, + sidecarPullImage: parameters.sidecarPullImage, + sidecarReadyCommand: parameters.sidecarReadyCommand, + sidecarEnvVars: parameters.sidecarEnvVars + ) { + echo "[INFO][${STEP_NAME}] Executing inside a Kubernetes Pod" body() } } @@ -218,7 +207,7 @@ void call(Map parameters = [:], body) { utils.unstashAll(config.stashContent) def image = docker.image(config.dockerImage) if (config.dockerPullImage) image.pull() - else echo"[INFO][$STEP_NAME] Skipped pull of image '${config.dockerImage}'." + else echo "[INFO][$STEP_NAME] Skipped pull of image '${config.dockerImage}'." if (!config.sidecarImage) { image.inside(getDockerOptions(config.dockerEnvVars, config.dockerVolumeBind, config.dockerOptions)) { body() @@ -226,28 +215,28 @@ void call(Map parameters = [:], body) { } else { def networkName = "sidecar-${UUID.randomUUID()}" sh "docker network create ${networkName}" - try{ + try { def sidecarImage = docker.image(config.sidecarImage) if (config.sidecarPullImage) sidecarImage.pull() - else echo"[INFO][$STEP_NAME] Skipped pull of image '${config.sidecarImage}'." - config.sidecarOptions = config.sidecarOptions?:[] + else echo "[INFO][$STEP_NAME] Skipped pull of image '${config.sidecarImage}'." + config.sidecarOptions = config.sidecarOptions ?: [] if (config.sidecarName) config.sidecarOptions.add("--network-alias ${config.sidecarName}") config.sidecarOptions.add("--network ${networkName}") sidecarImage.withRun(getDockerOptions(config.sidecarEnvVars, config.sidecarVolumeBind, config.sidecarOptions)) { container -> - config.dockerOptions = config.dockerOptions?:[] + config.dockerOptions = config.dockerOptions ?: [] if (config.dockerName) config.dockerOptions.add("--network-alias ${config.dockerName}") config.dockerOptions.add("--network ${networkName}") - if(config.sidecarReadyCommand) { - waitForSidecarReadyOnDocker(container.id, config.sidecarReadyCommand) + if (config.sidecarReadyCommand) { + sidecarUtils.waitForSidecarReadyOnDocker(container.id, config.sidecarReadyCommand) } image.inside(getDockerOptions(config.dockerEnvVars, config.dockerVolumeBind, config.dockerOptions)) { echo "[INFO][${STEP_NAME}] Running with sidecar container." body() } } - }finally{ + } finally { sh "docker network remove ${networkName}" } } @@ -259,41 +248,13 @@ void call(Map parameters = [:], body) { } } -private waitForSidecarReadyOnDocker(String containerId, String command){ - String dockerCommand = "docker exec ${containerId} ${command}" - waitForSidecarReady(dockerCommand) -} - -private waitForSidecarReadyOnKubernetes(String containerName, String command){ - container(name: containerName){ - waitForSidecarReady(command) - } -} - -private waitForSidecarReady(String command){ - int sleepTimeInSeconds = 10 - int timeoutInSeconds = 5 * 60 - int maxRetries = timeoutInSeconds / sleepTimeInSeconds - int retries = 0 - while(true){ - echo "Waiting for sidecar container" - String status = sh script:command, returnStatus:true - if(status == "0") return - if(retries > maxRetries){ - error("Timeout while waiting for sidecar container to be ready") - } - - sleep sleepTimeInSeconds - retries++ - } -} - /* * Returns a string with docker options containing * environment variables (if set). * Possible to extend with further options. * @param dockerEnvVars Map with environment variables */ + @NonCPS private getDockerOptions(Map dockerEnvVars, Map dockerVolumeBind, def dockerOptions) { def specialEnvironments = [ @@ -364,14 +325,15 @@ boolean isKubernetes() { * E.g. description=Lorem ipsum is * changed to description=Lorem\ ipsum. */ + @NonCPS def escapeBlanks(def s) { - def EQ='=' - def parts=s.split(EQ) + def EQ = '=' + def parts = s.split(EQ) - if(parts.length == 2) { - parts[1]=parts[1].replaceAll(' ', '\\\\ ') + if (parts.length == 2) { + parts[1] = parts[1].replaceAll(' ', '\\\\ ') s = parts.join(EQ) } diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index 876d478ed..e6dd5ed8f 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -1,3 +1,5 @@ +import com.sap.piper.SidecarUtils + import static com.sap.piper.Prerequisites.checkScript import com.sap.piper.ConfigurationHelper @@ -77,6 +79,40 @@ import hudson.AbortException * Specifies a dedicated user home directory for the container which will be passed as value for environment variable `HOME`. */ 'dockerWorkspace', + /** + * as `dockerImage` for the sidecar container + */ + 'sidecarImage', + /** + * SideCar only: + * Name of the container in local network. + */ + 'sidecarName', + /** + * Set this to 'false' to bypass a docker image pull. + * Usefull during development process. Allows testing of images which are available in the local registry only. + */ + 'sidecarPullImage', + /** + * Command executed inside the container which returns exit code 0 when the container is ready to be used. + */ + 'sidecarReadyCommand', + /** + * as `dockerEnvVars` for the sidecar container + */ + 'sidecarEnvVars', + /** + * as `dockerWorkspace` for the sidecar container + */ + 'sidecarWorkspace', + /** + * as `dockerVolumeBind` for the sidecar container + */ + 'sidecarVolumeBind', + /** + * as `dockerOptions` for the sidecar container + */ + 'sidecarOptions', /** Defines the Kubernetes nodeSelector as per [https://github.com/jenkinsci/kubernetes-plugin](https://github.com/jenkinsci/kubernetes-plugin).*/ 'nodeSelector', /** @@ -148,9 +184,9 @@ void call(Map parameters = [:], body) { Map config = configHelper.use() new Utils().pushToSWA([ - step: STEP_NAME, + step : STEP_NAME, stepParamKey1: 'scriptMissing', - stepParam1: parameters?.script == null + stepParam1 : parameters?.script == null ], config) if (!parameters.containerMap) { @@ -159,16 +195,16 @@ void call(Map parameters = [:], body) { config.containerMap = [(config.get('dockerImage')): config.containerName] config.containerCommands = config.containerCommand ? [(config.get('dockerImage')): config.containerCommand] : null } - executeOnPod(config, utils, body) + executeOnPod(config, utils, body, script) } } def getOptions(config) { def namespace = config.jenkinsKubernetes.namespace def options = [ - name : 'dynamic-agent-' + config.uniqueId, - label : config.uniqueId, - yaml : generatePodSpec(config) + name : 'dynamic-agent-' + config.uniqueId, + label: config.uniqueId, + yaml : generatePodSpec(config) ] if (namespace) { options.namespace = namespace @@ -182,7 +218,7 @@ def getOptions(config) { return options } -void executeOnPod(Map config, utils, Closure body) { +void executeOnPod(Map config, utils, Closure body, Script script) { /* * There could be exceptions thrown by - The podTemplate @@ -194,20 +230,23 @@ void executeOnPod(Map config, utils, Closure body) { * In case third case, we need to create the 'container' stash to bring the modified content back to the host. */ try { - + SidecarUtils sidecarUtils = new SidecarUtils(script) def stashContent = config.stashContent - if (config.containerName && stashContent.isEmpty()){ + if (config.containerName && stashContent.isEmpty()) { stashContent = [stashWorkspace(config, 'workspace')] } podTemplate(getOptions(config)) { node(config.uniqueId) { + if (config.sidecarReadyCommand) { + sidecarUtils.waitForSidecarReadyOnKubernetes(config.sidecarName, config.sidecarReadyCommand) + } if (config.containerName) { Map containerParams = [name: config.containerName] if (config.containerShell) { containerParams.shell = config.containerShell } echo "ContainerConfig: ${containerParams}" - container(containerParams){ + container(containerParams) { try { utils.unstashAll(stashContent) body() @@ -230,11 +269,11 @@ private String generatePodSpec(Map config) { def containers = getContainerList(config) def podSpec = [ apiVersion: "v1", - kind: "Pod", - metadata: [ + kind : "Pod", + metadata : [ lables: config.uniqueId ], - spec: [ + spec : [ containers: containers ] ] @@ -247,17 +286,17 @@ private String generatePodSpec(Map config) { private String stashWorkspace(config, prefix, boolean chown = false, boolean stashBack = false) { def stashName = "${prefix}-${config.uniqueId}" try { - if (chown) { + if (chown) { def securityContext = getSecurityContext(config) def runAsUser = securityContext?.runAsUser ?: 1000 def fsGroup = securityContext?.fsGroup ?: 1000 - sh """#!${config.containerShell?:'/bin/sh'} + sh """#!${config.containerShell ?: '/bin/sh'} chown -R ${runAsUser}:${fsGroup} .""" } def includes, excludes - if(stashBack) { + if (stashBack) { includes = config.stashIncludes.stashBack ?: config.stashIncludes.workspace excludes = config.stashExcludes.stashBack ?: config.stashExcludes.workspace } else { @@ -272,7 +311,6 @@ chown -R ${runAsUser}:${fsGroup} .""" ) //inactive due to negative side-effects, we may require a dedicated git stash to be used //useDefaultExcludes: false) - return stashName } catch (AbortException | IOException e) { echo "${e.getMessage()}" @@ -296,21 +334,21 @@ private List getContainerList(config) { //If no custom jnlp agent provided as default jnlp agent (jenkins/jnlp-slave) as defined in the plugin, see https://github.com/jenkinsci/kubernetes-plugin#pipeline-support def result = [] - //allow definition of jnlp image via environment variable JENKINS_JNLP_IMAGE in the Kubernetes landscape or via config as fallback if (env.JENKINS_JNLP_IMAGE || config.jenkinsKubernetes.jnlpAgent) { result.push([ - name: 'jnlp', + name : 'jnlp', image: env.JENKINS_JNLP_IMAGE ?: config.jenkinsKubernetes.jnlpAgent ]) } config.containerMap.each { imageName, containerName -> def containerPullImage = config.containerPullImageFlags?.get(imageName) + boolean pullImage = containerPullImage != null ? containerPullImage : config.dockerPullImage def containerSpec = [ - name: containerName.toLowerCase(), - image: imageName, - imagePullPolicy: containerPullImage ? "Always" : "IfNotPresent", - env: getContainerEnvs(config, imageName) + name : containerName.toLowerCase(), + image : imageName, + imagePullPolicy: pullImage ? "Always" : "IfNotPresent", + env : getContainerEnvs(config, imageName) ] def configuredCommand = config.containerCommands?.get(imageName) @@ -321,32 +359,43 @@ private List getContainerList(config) { '-f', '/dev/null' ] - } else if(configuredCommand != "") { + } else if (configuredCommand != "") { // apparently "" is used as a flag for not settings container commands !? containerSpec['command'] = - (configuredCommand in List) ? configuredCommand : [ - shell, - '-c', - configuredCommand - ] + (configuredCommand in List) ? configuredCommand : [ + shell, + '-c', + configuredCommand + ] } if (config.containerPortMappings?.get(imageName)) { def ports = [] def portCounter = 0 - config.containerPortMappings.get(imageName).each {mapping -> + config.containerPortMappings.get(imageName).each { mapping -> def name = "${containerName}${portCounter}".toString() - if(mapping.containerPort != mapping.hostPort) { - echo ("[WARNING][${STEP_NAME}]: containerPort and hostPort are different for container '${containerName}'. " + if (mapping.containerPort != mapping.hostPort) { + echo("[WARNING][${STEP_NAME}]: containerPort and hostPort are different for container '${containerName}'. " + "The hostPort will be ignored.") } ports.add([name: name, containerPort: mapping.containerPort]) - portCounter ++ + portCounter++ } containerSpec.ports = ports } result.push(containerSpec) } + if (config.sidecarImage) { + def containerSpec = [ + name : config.sidecarName.toLowerCase(), + image : config.sidecarImage, + imagePullPolicy: config.sidecarPullImage ? "Always" : "IfNotPresent", + env : getContainerEnvs(config, config.sidecarImage), + command : [] + ] + + result.push(containerSpec) + } return result } @@ -356,13 +405,14 @@ private List getContainerList(config) { * (Kubernetes-Plugin only!) * @param config Map with configurations */ + private List getContainerEnvs(config, imageName) { def containerEnv = [] def dockerEnvVars = config.containerEnvVars?.get(imageName) ?: config.dockerEnvVars ?: [:] def dockerWorkspace = config.containerWorkspaces?.get(imageName) != null ? config.containerWorkspaces?.get(imageName) : config.dockerWorkspace ?: '' def envVar = { e -> - [ name: e.key, value: e.value ] + [name: e.key, value: e.value] } if (dockerEnvVars) { From a9fd63f0d606309294d935fca0d7495c8797ed52 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 13 Sep 2019 16:01:17 +0200 Subject: [PATCH 045/141] Remove handleDeprecation since it might be a irrelevant cause for review discussions --- src/com/sap/piper/ConfigurationLoader.groovy | 21 -------------------- 1 file changed, 21 deletions(-) diff --git a/src/com/sap/piper/ConfigurationLoader.groovy b/src/com/sap/piper/ConfigurationLoader.groovy index c6d5200ae..99c611146 100644 --- a/src/com/sap/piper/ConfigurationLoader.groovy +++ b/src/com/sap/piper/ConfigurationLoader.groovy @@ -15,28 +15,12 @@ class ConfigurationLoader implements Serializable { return loadConfiguration('steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) } - /* - * By default this methods does nothing. With this method we are able to ensure that we do not call the - * deprecated methods. Might be usefull during local development. - */ - private static handleDeprecation(script, String methodName) { - if(script != null) { - def msg = "ConfigurationLoader.${methodName} was called with a script reference." + - 'This method is deprecated. Use the same method without the script reference' - if(Boolean.getBoolean('com.sap.piper.failOnScriptReferenceInConfigurationLoader')) - throw new RuntimeException(msg) - if(Boolean.getBoolean('com.sap.piper.emitWarningOnScriptReferenceInConfigurationLoader') && - script instanceof Script) script.echo("[WARNING] ${msg}") - } - } - static Map stageConfiguration(String stageName) { stageConfiguration(null, stageName) } @Deprecated /** Use stageConfiguration(stageName) instead */ static Map stageConfiguration(script, String stageName) { - handleDeprecation(script, 'stageConfiguration') return loadConfiguration('stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) } @@ -46,7 +30,6 @@ class ConfigurationLoader implements Serializable { @Deprecated /** Use defaultStepConfiguration(stepName) instead */ static Map defaultStepConfiguration(script, String stepName) { - handleDeprecation(script, 'defaultStepConfiguration') return loadConfiguration('steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) } @@ -56,7 +39,6 @@ class ConfigurationLoader implements Serializable { @Deprecated /** Use defaultStageConfiguration(stepName) instead */ static Map defaultStageConfiguration(script, String stageName) { - handleDeprecation(script, 'defaultStageConfiguration') return loadConfiguration('stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) } @@ -66,7 +48,6 @@ class ConfigurationLoader implements Serializable { @Deprecated /** Use generalConfiguration() instead */ static Map generalConfiguration(script){ - handleDeprecation(script, 'generalConfiguration') try { return CommonPipelineEnvironment.getInstance()?.configuration?.general ?: [:] } catch (groovy.lang.MissingPropertyException mpe) { @@ -80,7 +61,6 @@ class ConfigurationLoader implements Serializable { @Deprecated /** Use defaultGeneralConfiguration() instead */ static Map defaultGeneralConfiguration(script){ - handleDeprecation(script, 'defaultGeneralConfiguration') return DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] } @@ -90,7 +70,6 @@ class ConfigurationLoader implements Serializable { @Deprecated /** Use postActionConfiguration() instead */ static Map postActionConfiguration(script, String actionName){ - handleDeprecation(script, 'postActionConfiguration') return loadConfiguration('postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) } From a1d7c5584989ee6e4419b994fa17ab49519dd94a Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 13 Sep 2019 16:10:38 +0200 Subject: [PATCH 046/141] No call from method to correponding deprecated method, but the other way around. --- src/com/sap/piper/ConfigurationLoader.groovy | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/com/sap/piper/ConfigurationLoader.groovy b/src/com/sap/piper/ConfigurationLoader.groovy index 99c611146..30f71c8f5 100644 --- a/src/com/sap/piper/ConfigurationLoader.groovy +++ b/src/com/sap/piper/ConfigurationLoader.groovy @@ -7,70 +7,70 @@ package com.sap.piper class ConfigurationLoader implements Serializable { static Map stepConfiguration(String stepName) { - return stepConfiguration(null, stepName) + return loadConfiguration('steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) } @Deprecated /** Use stepConfiguration(stepName) instead */ static Map stepConfiguration(script, String stepName) { - return loadConfiguration('steps', stepName, ConfigurationType.CUSTOM_CONFIGURATION) + return stepConfiguration(stepName) } static Map stageConfiguration(String stageName) { - stageConfiguration(null, stageName) + return loadConfiguration('stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) } @Deprecated /** Use stageConfiguration(stageName) instead */ static Map stageConfiguration(script, String stageName) { - return loadConfiguration('stages', stageName, ConfigurationType.CUSTOM_CONFIGURATION) + return stageConfiguration(stageName) } static Map defaultStepConfiguration(String stepName) { - defaultStepConfiguration(null, stepName) + return loadConfiguration('steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) } @Deprecated /** Use defaultStepConfiguration(stepName) instead */ static Map defaultStepConfiguration(script, String stepName) { - return loadConfiguration('steps', stepName, ConfigurationType.DEFAULT_CONFIGURATION) + return defaultStepConfiguration(stepName) } static Map defaultStageConfiguration(String stageName) { - defaultStageConfiguration(null, stageName) + return loadConfiguration('stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) } @Deprecated /** Use defaultStageConfiguration(stepName) instead */ static Map defaultStageConfiguration(script, String stageName) { - return loadConfiguration('stages', stageName, ConfigurationType.DEFAULT_CONFIGURATION) + return defaultStageConfiguration(stageName) } static Map generalConfiguration(){ - generalConfiguration(null) - } - @Deprecated - /** Use generalConfiguration() instead */ - static Map generalConfiguration(script){ try { return CommonPipelineEnvironment.getInstance()?.configuration?.general ?: [:] } catch (groovy.lang.MissingPropertyException mpe) { return [:] } } + @Deprecated + /** Use generalConfiguration() instead */ + static Map generalConfiguration(script){ + return generalConfiguration() + } static Map defaultGeneralConfiguration(){ - defaultGeneralConfiguration(null) + return DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] } @Deprecated /** Use defaultGeneralConfiguration() instead */ static Map defaultGeneralConfiguration(script){ - return DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] + return defaultGeneralConfiguration() } static Map postActionConfiguration(String actionName){ - postActionConfiguration(null, actionName) + return loadConfiguration('postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) } @Deprecated /** Use postActionConfiguration() instead */ static Map postActionConfiguration(script, String actionName){ - return loadConfiguration('postActions', actionName, ConfigurationType.CUSTOM_CONFIGURATION) + return postActionConfiguration(actionName) } private static Map loadConfiguration(String type, String entryName, ConfigurationType configType){ From fce8098f982ac71cdb2db016f0d28762e92e2cc5 Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Mon, 16 Sep 2019 10:02:21 +0200 Subject: [PATCH 047/141] Avoid full merge trace being added to culprits --- test/groovy/MailSendNotificationTest.groovy | 4 ++-- vars/mailSendNotification.groovy | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/groovy/MailSendNotificationTest.groovy b/test/groovy/MailSendNotificationTest.groovy index 1964f3570..408f2a3b4 100644 --- a/test/groovy/MailSendNotificationTest.groovy +++ b/test/groovy/MailSendNotificationTest.groovy @@ -58,10 +58,10 @@ user3@domain.com noreply+github@domain.com''' @Test void testCulpritsFromGitCommit() throws Exception { - def gitCommand = "git log -2 --pretty=format:'%ae %ce'" + def gitCommand = "git log -2 --first-parent --pretty=format:'%ae %ce'" def expected = "user2@domain.com user3@domain.com" - shellRule.setReturnValue("git log -2 --pretty=format:'%ae %ce'", 'user2@domain.com user3@domain.com') + shellRule.setReturnValue("git log -2 --first-parent --pretty=format:'%ae %ce'", 'user2@domain.com user3@domain.com') def result = stepRule.step.getCulprits( [ diff --git a/vars/mailSendNotification.groovy b/vars/mailSendNotification.groovy index 1c898b636..faeea1ba2 100644 --- a/vars/mailSendNotification.groovy +++ b/vars/mailSendNotification.groovy @@ -223,7 +223,7 @@ def getCulprits(config, branch, numberOfCommits) { } } - def recipients = sh(returnStdout: true, script: "git log -${numberOfCommits} --pretty=format:'%ae %ce'") + def recipients = sh(returnStdout: true, script: "git log -${numberOfCommits} --first-parent --pretty=format:'%ae %ce'") return getDistinctRecipients(recipients) } catch(err) { echo "[${STEP_NAME}] Culprit retrieval from git failed with '${err.getMessage()}'. Please make sure to configure gitSshKeyCredentialsId. So far, only fixed list of recipients is used." From 54cbc403c0d217736a2af0468c5770726f2b0024 Mon Sep 17 00:00:00 2001 From: Sarah Noack Date: Wed, 18 Sep 2019 13:46:40 +0200 Subject: [PATCH 048/141] Add SAP Cloud Platform Transport Management scenario --- .../docs/images/Detailed_Process_TMS.png | Bin 0 -> 36687 bytes documentation/docs/images/Interplay_TMS.png | Bin 0 -> 63416 bytes documentation/docs/scenarios/TMS_Extension.md | 75 ++++++++++++++++++ documentation/mkdocs.yml | 1 + 4 files changed, 76 insertions(+) create mode 100644 documentation/docs/images/Detailed_Process_TMS.png create mode 100644 documentation/docs/images/Interplay_TMS.png create mode 100644 documentation/docs/scenarios/TMS_Extension.md diff --git a/documentation/docs/images/Detailed_Process_TMS.png b/documentation/docs/images/Detailed_Process_TMS.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc898683ea44722f3877e938bdd7600817a99e2 GIT binary patch literal 36687 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 A+W-In literal 0 HcmV?d00001 diff --git a/documentation/docs/images/Interplay_TMS.png b/documentation/docs/images/Interplay_TMS.png new file mode 100644 index 0000000000000000000000000000000000000000..ac88ca5c0afa54f330a157b50bb15ee7cd936c6a GIT binary patch literal 63416 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 i+^+ne7r(x}KegiVR6 Date: Wed, 18 Sep 2019 14:15:02 +0200 Subject: [PATCH 049/141] minor language editing --- documentation/docs/scenarios/TMS_Extension.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/documentation/docs/scenarios/TMS_Extension.md b/documentation/docs/scenarios/TMS_Extension.md index 62749fc84..d2e2ee78e 100644 --- a/documentation/docs/scenarios/TMS_Extension.md +++ b/documentation/docs/scenarios/TMS_Extension.md @@ -4,7 +4,7 @@ Extend your CI/CD pipeline with SAP Cloud Platform Transport Management to add a ## Context -This procedure explains how to upload a [multitartget application](https://www.sap.com/documents/2016/06/e2f618e4-757c-0010-82c7-eda71af511fa.html) published on SAP site out of a CI/CD pipeline to SAP Cloud Platform Transport Management and then import it into its target environment. +This procedure explains how to upload a [multitartget application](https://www.sap.com/documents/2016/06/e2f618e4-757c-0010-82c7-eda71af511fa.html) from a CI/CD pipeline to SAP Cloud Platform Transport Management and then import it into its target environment. SAP Cloud Platform Transport Management allows you to manage the transport of development artifacts and application-specific content between different SAP Cloud Platform accounts. It adds transparency to the audit trail of changes so that you get information about who performed which changes in your production accounts and when they did it. At the same time, the Transport Management service enables a separation of concerns: For example, a developer of an application or SAP Cloud Platform content artifacts can trigger the propagation of changes, while the resulting transport is handled by a central operations team. For more information, see [SAP Cloud Platform Transport Management](https://help.sap.com/viewer/product/TRANSPORT_MANAGEMENT_SERVICE/Cloud/en-US). @@ -22,7 +22,7 @@ The following graphic provides an overview about the interplay between continuou ## Procedure -This procedure belongs to the Extend Your Pipeline category, which means that you can use it to extend any CI process that meets the prerequisites, for example, the one described in [Build and Deploy SAPUI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins](https://sap.github.io/jenkins-library/scenarios/ui5-sap-cp/Readme/). +You can use this scenario to extend any CI process that meets the prerequisites, for example, the one described in [Build and Deploy SAPUI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins](https://sap.github.io/jenkins-library/scenarios/ui5-sap-cp/Readme/). The following graphic shows an example of the detailed procedure when combining continuous integration and SAP Cloud Platform Transport Management: @@ -40,7 +40,7 @@ The process flow contains the following steps: ### Jenkinsfile -Following the convention for pipeline definitions, use a Jenkinsfile which resides in the root directory of your development sources. +Following the convention for pipeline definitions, use a Jenkinsfile, which resides in the root directory of your development sources. ```groovy @Library('piper-lib-os') _ @@ -65,11 +65,11 @@ steps: | Parameter | Description | | -------------------|-------------| -| `credentialsId` |Credentials to be used for the file and node uploads to the Transport Management Service.| -| `nodeName`|Defines the name of the node to which the *.mtar file should be uploaded.| +| `credentialsId` |Credentials that are used for the file and node uploads to the Transport Management Service.| +| `nodeName`|Defines the name of the node to which the *.mtar file is uploaded.| | `mtaPath`|Defines the path to *.mtar for the upload to the Transport Management Service.| -| `customDescription`| Can be used as the description of a transport request. Will overwrite the default. (Default: Corresponding Git Commit-ID)| +| `customDescription`| Can be used as description of a transport request. Overwrites the default (Default: Corresponding Git Commit-ID).| ### Parameters -For the detailed description of the relevant parameters, see [tmsUpload](../../../steps/tmsUpload/) +For a detailed description of the relevant parameters, see [tmsUpload](../../../steps/tmsUpload/). From a0bdc4f4c94a1f36ba6968b68076bf0bb1c979b8 Mon Sep 17 00:00:00 2001 From: Sarah Noack Date: Wed, 18 Sep 2019 14:56:41 +0200 Subject: [PATCH 050/141] Add description in Jenkinsfile section --- documentation/docs/scenarios/TMS_Extension.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/docs/scenarios/TMS_Extension.md b/documentation/docs/scenarios/TMS_Extension.md index d2e2ee78e..d5f851b57 100644 --- a/documentation/docs/scenarios/TMS_Extension.md +++ b/documentation/docs/scenarios/TMS_Extension.md @@ -40,6 +40,8 @@ The process flow contains the following steps: ### Jenkinsfile +If you use the pipeline of the following code snippet, you only have to configure it in the .pipeline/config.yml. + Following the convention for pipeline definitions, use a Jenkinsfile, which resides in the root directory of your development sources. ```groovy @@ -61,7 +63,7 @@ steps: customDescription: Custom-Transport-Description ``` -#### Configration for the upload to Transport Management +#### Configration for the Upload to Transport Management | Parameter | Description | | -------------------|-------------| From 1c8ba63053b037b0396e8e43f83ca858ed8a5a8c Mon Sep 17 00:00:00 2001 From: Sarah Noack Date: Thu, 19 Sep 2019 09:47:03 +0200 Subject: [PATCH 051/141] Change node decription --- documentation/docs/scenarios/TMS_Extension.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/scenarios/TMS_Extension.md b/documentation/docs/scenarios/TMS_Extension.md index d5f851b57..4b6d85570 100644 --- a/documentation/docs/scenarios/TMS_Extension.md +++ b/documentation/docs/scenarios/TMS_Extension.md @@ -58,7 +58,7 @@ This is a basic configuration example, which is also located in the sources of t steps: tmsUpload: credentialsId: tms-secret-key - nodeName: a_piper_node + nodeName: tms_target_node mtaPath: com.piper.example.tms.mtar customDescription: Custom-Transport-Description ``` From e041f45183255d7b6ba2f923f3060a8e1eb729a9 Mon Sep 17 00:00:00 2001 From: Florian Wilhelm Date: Thu, 19 Sep 2019 14:17:41 +0200 Subject: [PATCH 052/141] Streamline documentation to reflect project "piper" (#875) --- documentation/docs/configuration.md | 5 +- documentation/docs/extensibility.md | 9 ++- documentation/docs/guidedtour.md | 31 +++++++--- .../docs/images/cloud-sdk-pipeline.png | Bin 0 -> 80691 bytes .../docs/images/webide-pipeline-template.png | Bin 0 -> 130101 bytes documentation/docs/index.md | 49 ++++++++++------ .../docs/pipelines/cloud-sdk/introduction.md | 41 +++++++++++++ documentation/docs/scenarios/CAP_Scenario.md | 54 +++++++++++++++--- documentation/mkdocs.yml | 47 +++++++-------- 9 files changed, 178 insertions(+), 58 deletions(-) create mode 100644 documentation/docs/images/cloud-sdk-pipeline.png create mode 100644 documentation/docs/images/webide-pipeline-template.png create mode 100644 documentation/docs/pipelines/cloud-sdk/introduction.md diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 5be9daafc..18653e40f 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -1,6 +1,9 @@ # Configuration -Configuration is done via a yml-file, located at `.pipeline/config.yml` in the **master branch** of your source code repository. +Configure your project through a yml-file, which is located at `.pipeline/config.yml` in the **master branch** of your source code repository. + +!!! note "Cloud SDK Pipeline" + Cloud SDK Pipelines are configured in a file called `pipeline_config.yml`. See [SAP Cloud SDK Pipeline Configuration Docs](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/configuration.md). Your configuration inherits from the default configuration located at [https://github.com/SAP/jenkins-library/blob/master/resources/default_pipeline_environment.yml](https://github.com/SAP/jenkins-library/blob/master/resources/default_pipeline_environment.yml). diff --git a/documentation/docs/extensibility.md b/documentation/docs/extensibility.md index 0042534ae..bc84188bb 100644 --- a/documentation/docs/extensibility.md +++ b/documentation/docs/extensibility.md @@ -4,10 +4,13 @@ There are several possibilities for extensibility besides the **[very powerful c ## 1. Stage Exits - You have to create a file like `.groovy` for example `Acceptance.groovy` and store it in folder `.pipeline/extensions/` in your source code repository. + You have to create a file like `.groovy` (for example, `Acceptance.groovy`) and store it in folder `.pipeline/extensions/` in your source code repository. - The pipeline template will check if such a file exists and executes it if present. - A parameter is passed to the extension containing following keys: +!!! note "Cloud SDK Pipeline" + If you use the Cloud SDK Pipeline, the folder is named `pipeline/extensions/` (without the dot). For more information, please refer to [the Cloud SDK Pipeline documentation](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/doc/pipeline/extensibility.md). + + The pipeline template checks if such a file exists and executes it, if present. + A parameter that contains the following keys is passed to the extension: * `script`: defines the global script environment of the Jenkinsfile run. This makes sure that the correct configuration environment can be passed to project "Piper" steps and also allows access to for example the `commonPipelineEnvironment`. * `originalStage`: this will allow you to execute the "original" stage at any place in your script. If omitting a call to `originalStage()` only your code will be executed instead. diff --git a/documentation/docs/guidedtour.md b/documentation/docs/guidedtour.md index a47848e66..c2613863d 100644 --- a/documentation/docs/guidedtour.md +++ b/documentation/docs/guidedtour.md @@ -12,29 +12,46 @@ The stated instructions assume the use of this application. * You have installed a Linux system with at least 4 GB memory. **Note:** We have tested our samples on Ubuntu 16.04. On Microsoft Windows, you might face some issues. * You have installed the newest version of Docker. See [Docker Community Edition](https://docs.docker.com/install/). **Note:** we have tested on Docker 18.09.6. -* You have installed Jenkins 2.60.3 or higher. **Recommendation:** We recommend to use the `cx-server` toolkit. See **(Optional) Install the `cx-server` Toolkit for Jenkins**. **Note:** If you use your **own Jenkins installation** you need to care for "Piper" specific configuration. Follow [my own Jenkins installation][guidedtour-my-own-jenkins]. * Your system has access to [GitHub.com][github]. -## (Optional) Install the `cx-server` Toolkit for Jenkins +## **Recommended:** Install the Cx Server Life-cycle Management for Jenkins -`cx-server`is a lifecycle management toolkit that provides Docker images with a preconfigured Jenkins and a Nexus-based cache to facilitate the configuration and usage of Jenkins. +Cx Server is a life-cycle management tool to bootstrap a pre-configured Jenkins instance within minutes. +All required plugins and shared libraries are included automatically. +It is based on Docker images provided by project "Piper". -To use the toolkit, get the `cx-server` script and its configuration file `server.cfg` by using the following command: +To get started, initialize Cx Server by using this `docker run` command: ```sh docker run -it --rm -u $(id -u):$(id -g) -v "${PWD}":/cx-server/mount/ ppiper/cx-server-companion:latest init-cx-server ``` -When the files are downloaded into the current directory, launch the Jenkins server by using the following command: +This creates a few files in your current working directory. +The shell script `cx-server` and the configuration file `server.cfg` are of special interest. + +Now, you can start the Jenkins server by using the following command: ```sh +chmod +x ./cx-server ./cx-server start ``` -For more information on the Jenkins lifecycle management and how to customize your Jenkins, have a look at the [Operations Guide for Cx Server][devops-docker-images-cxs-guide]. +For more information on the Cx Server and how to customize your Jenkins, have a look at the [Operations Guide for Cx Server][devops-docker-images-cxs-guide]. + +### On your own: Custom Jenkins Setup + +If you use your own Jenkins installation, you need to care for the configuration that is specific to project "Piper". +This option should only be considered if you know why you need it, otherwise using the Cx Server life-cycle management makes your life much easier. +If you choose to go this path, follow [my own Jenkins installation][guidedtour-my-own-jenkins] for some hints. + +**Note:** This option is not supported for SAP Cloud SDK projects. ## (Optional) Sample Application +!!! info "Choosing the best sample application" + Depending on the type of project you're interested in, different sample applications might be interesting. + For SAP Cloud SDK, please have a look at the [Address Manager](https://github.com/sap/cloud-s4-sdk-book) example application. + Copy the sources of the application into your own Git repository. While we will ask you to fork the application's repository into a **GitHub** space, you can use any version control system based on Git like **GitLab** or **plain git**. **Note:** A `public` GitHub repository is visible to the public. The configuration files may contain data you don't want to expose, so use a `private` repository. 1. Create an organization on GitHub, if you haven't any yet. See [Creating a new organization][github-create-org]. @@ -189,7 +206,7 @@ Please also consult the blog post on setting up [Continuous Delivery for S/4HANA [sap-blog-s4-sdk-first-steps]: https://blogs.sap.com/2017/05/10/first-steps-with-sap-s4hana-cloud-sdk/ [sap-blog-ci-cd]: https://blogs.sap.com/2017/09/20/continuous-integration-and-delivery/ -[devops-docker-images-cxs-guide]: https://github.com/SAP/devops-docker-images/blob/master/docs/operations/cx-server-operations-guide.md +[devops-docker-images-cxs-guide]: https://github.com/SAP/devops-docker-cx-server/blob/master/docs/operations/cx-server-operations-guide.md [cloud-cf-helloworld-nodejs]: https://github.com/SAP/cloud-cf-helloworld-nodejs [github]: https://github.com diff --git a/documentation/docs/images/cloud-sdk-pipeline.png b/documentation/docs/images/cloud-sdk-pipeline.png new file mode 100644 index 0000000000000000000000000000000000000000..5119d8ab822370e3c3e9c7f395395a4a61a80ad7 GIT binary patch literal 80691 zcmZ^LbySB#Fo2X87ytkW z=iv@iKAYv?1=B-MMG>QF1iX!bA&;S|tnktob1w@wAhU1$9+70L?*5tg;_(IkfoQ)a z!~9EqMS}1IOEq%iIrSZNd{y%#G*)4PqGUoiK61tftH9ZjWGIj?P#VC5`L9b|mITVa z?{|9ty7?b%B7;e|E+hY!4j$UdPl7Ik6(NoH5=u(S@a*Hx3|heR=g;-@^xTI`7-c>@D;M=p!@QH10QB|sx$n&g-Y8M; zerj}C=3@|bBF(08ROHkNGD~6HZ4oU#{wh{Hblvjd-%s^S7HA%D^xW^Dd9%>(WFsjh z#U@8Qg!gX*`(^WJpw`B(49+NG#-!GRmfaMZUct7_Y=4`X8Y}l6BC$+&*@X~x71+Q< z$AwJu-&7NupI-!5_�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 literal 0 HcmV?d00001 diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 96b83c976..691ab0efc 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -1,32 +1,45 @@ # Project "Piper" User Documentation -An efficient software development process is vital for success in building -business applications on SAP Cloud Platform or SAP on-premise platforms. SAP -addresses this need for efficiency with project "Piper". The goal of project -"Piper" is to substantially ease setting up continuous delivery processes for -the most important SAP technologies by means of Jenkins pipelines. +Continuous delivery is a method to develop software with short feedback cycles. +It is applicable to projects both for SAP Cloud Platform and SAP on-premise platforms. +SAP implements tooling for continuous delivery in project "Piper". +The goal of project "Piper" is to substantially ease setting up continuous delivery in your project using SAP technologies. ## What you get -Project "Piper" consists of two parts: +To get you started quickly, project "Piper" offers you the following artifacts: -* [A shared library][piper-library] containing steps and utilities that are - required by Jenkins pipelines. -* A set of [Docker images][devops-docker-images] used in the piper library to implement best practices. +* A set of ready-made Continuous Delivery pipelines for direct use in your project + * [General Purpose Pipeline](stages/introduction/) + * [SAP Cloud SDK Pipeline][cloud-sdk-pipeline] +* [A shared library][piper-library] that contains reusable step implementations, which enable you to customize our preconfigured pipelines, or to even build your own customized ones +* A set of [Docker images][devops-docker-images] to setup a CI/CD environment in minutes using sophisticated life-cycle management -The shared library contains all the necessary steps to run our best practice -Jenkins pipelines described in the Scenarios section or -to run a [pipeline as step][piper-library-scenario]. +To find out which offering is right for you, we recommend to look at the ready-made pipelines first. +In many cases, they should satisfy your requirements, and if this is the case, you don't need to build your own pipeline. -The best practice pipelines are based on the general concepts of [Jenkins 2.0 -Pipelines as Code][jenkins-doc-pipelines]. With that you have the power of the -Jenkins community at hand to optimize your pipelines. +### The best-practice way: Ready-made pipelines + +**Are you building a standalone SAP Cloud Platform application?
** +Then continue reading about our [general purpose pipeline](stages/introduction/), which supports various technologies and programming languages. + +**Are you building an application with the SAP Cloud SDK and/or SAP Cloud Application Programming Model?
** +Then we can offer you a [pipeline specifically tailored to SAP Cloud SDK and SAP Cloud Application Programming Model applications][cloud-sdk-pipeline] + +### The do-it-yourself way: Build with Library + +The shared library contains building blocks for your own pipeline, following our best practice Jenkins pipelines described in the Scenarios section. + +The best practice pipelines are based on the general concepts of [Pipelines as Code, as introduced in Jenkins 2][jenkins-doc-pipelines]. +With that you have the power of the Jenkins community at hand to optimize your pipelines. You can run the best practice Jenkins pipelines out of the box, take them as a starting point for project-specific adaptations or implement your own pipelines from scratch using the shared library. -## Extensibility +For an example, you might want to check out our ["Build and Deploy SAPUI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins" scenario][piper-library-scenario]. + +#### Extensibility If you consider adding additional capabilities to your `Jenkinsfile`, consult the [Jenkins Pipeline Steps Reference][jenkins-doc-steps]. There, you get an @@ -41,7 +54,7 @@ Custom library steps can be added using a custom library according to the groovy coding to the `Jenkinsfile`. Your custom library can coexist next to the provided pipeline library. -## API +#### API All steps (`vars` and `resources` directory) are intended to be used by Pipelines and are considered API. All the classes / groovy-scripts contained in the `src` folder are by default not part of @@ -49,8 +62,10 @@ the API and are subjected to change without prior notice. Types and methods anno `@API` are considered to be API, used e.g. from other shared libraries. Changes to those methods/types needs to be announced, discussed and agreed. + [github]: https://github.com [piper-library]: https://github.com/SAP/jenkins-library +[cloud-sdk-pipeline]: pipelines/cloud-sdk/introduction/ [devops-docker-images]: https://github.com/SAP/devops-docker-images [devops-docker-images-issues]: https://github.com/SAP/devops-docker-images/issues [devops-docker-images-cxs-guide]: https://github.com/SAP/devops-docker-images/blob/master/docs/operations/cx-server-operations-guide.md diff --git a/documentation/docs/pipelines/cloud-sdk/introduction.md b/documentation/docs/pipelines/cloud-sdk/introduction.md new file mode 100644 index 000000000..62eae78ca --- /dev/null +++ b/documentation/docs/pipelines/cloud-sdk/introduction.md @@ -0,0 +1,41 @@ +# SAP Cloud SDK Pipeline + +SAP Cloud SDK for Continuous Delivery Logo
+ +If you are building an application with [SAP Cloud SDK](https://community.sap.com/topics/cloud-sdk), the [SAP Cloud SDK pipeline](https://github.com/SAP/cloud-s4-sdk-pipeline) helps you to quickly build and deliver your app in high quality. +Thanks to highly streamlined components, setting up and delivering your first project will just take minutes. + +## Qualities and Pipeline Features + +The SAP Cloud SDK pipeline is based on project "piper" and offers unique features for assuring that your SAP Cloud SDK based application fulfills highest quality standards. +In conjunction with the SAP Cloud SDK libraries, the pipeline helps you to implement and automatically assure application qualities, for example: + +* Functional correctness via: + * Backend and frontend unit tests + * Backend and frontend integration tests + * User acceptance testing via headless browser end-to-end tests +* Non-functional qualities via: + * Dynamic resilience checks + * Performance tests based on *Gatling* or *JMeter* + * Code Security scans based on *Checkmarx* and *Fortify* + * Dependency vulnerability scans based on *Whitesource* + * IP compliance scan based on *Whitesource* + * Zero-downtime deployment + * Proper logging of application errors + +![Screenshot of SAP Cloud SDK Pipeline](../../images/cloud-sdk-pipeline.png) + +## Supported Project Types + +The pipeline supports the following types of projects: + +* Java projects based on the [SAP Cloud SDK Archetypes](https://mvnrepository.com/artifact/com.sap.cloud.sdk.archetypes). +* JavaScript projects based on the [SAP Cloud SDK JavaScript Scaffolding](https://github.com/SAP/cloud-s4-sdk-examples/tree/scaffolding-js). +* TypeScript projects based on the [SAP Cloud SDK TypeScript Scaffolding](https://github.com/SAP/cloud-s4-sdk-examples/tree/scaffolding-ts). +* SAP Cloud Application Programming Model (CAP) projects based on the _SAP Cloud Platform Business Application_ WebIDE Template. + +You can find more details about the supported project types and build tools in the [project documentation](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/doc/pipeline/build-tools.md). + +## Legal Notes + +Note: This license of this repository does not apply to the SAP Cloud SDK for Continuous Delivery Logo referenced in this page diff --git a/documentation/docs/scenarios/CAP_Scenario.md b/documentation/docs/scenarios/CAP_Scenario.md index b597fef99..b84b72f9d 100644 --- a/documentation/docs/scenarios/CAP_Scenario.md +++ b/documentation/docs/scenarios/CAP_Scenario.md @@ -1,9 +1,49 @@ -# Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model +# Build and Deploy SAP Cloud Application Programming Model Applications -Set up a basic continuous delivery process for developing applications according to the SAP Cloud Application Programming Model. If you're building extensions of SAP solutions such as SAP S/4HANA, consider using [SAP Cloud SDK](https://developers.sap.com/topics/cloud-sdk.html) and [SAP Cloud SDK Pipeline](https://github.com/SAP/cloud-s4-sdk-pipeline) which provides an out-of-the-box continuous delivery pipeline based on project "Piper". +In this scenario, we will setup a CI/CD Pipeline for a SAP Cloud Application Programming Model (CAP) project, which is based on the _SAP Cloud Platform Business Application_ WebIDE Template. ## Prerequisites +* You have an account on SAP Cloud Platform in the Cloud Foundry environment. See [Accounts](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/8ed4a705efa0431b910056c0acdbf377.html). +* You have setup a suitable Jenkins instance as described in [Guided Tour](../guidedtour.md) + +## Context + +The Application Programming Model for SAP Cloud Platform is an end-to-end best practice guide for developing applications on SAP Cloud Platform and provides a supportive set of APIs, languages, and libraries. +For more information about the SAP Cloud Application Programming Model, see [Working with the SAP Cloud Application Programming Model](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/00823f91779d4d42aa29a498e0535cdf.html). + +## Getting started + +To get started, generate a project in SAP Web IDE based on the _SAP Cloud Platform Business Application_ template. +Make sure to check the Include support for continuous delivery pipeline of SAP Cloud SDK checkbox, as in this screenshot: + +![WebIDE project wizard](../images/webide-pipeline-template.png) + +This will generate a project which already includes a `Jenkinsfile`, and a `pipeline_config.yml` file. + +In case you already created your project without this option, you'll need to copy and paste two files into the root directory of your project, and commit them to your git repository: + +* [`Jenkinsfile`](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/archetype-resources/Jenkinsfile) +* [`pipeline_config.yml`](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/archetype-resources/cf-pipeline_config.yml) + * Note: The file must be named `pipeline_config.yml`, despite the different name of the file template + +!!! note "Using the right project structure" + This only applies to projects created based on the _SAP Cloud Platform Business Application_ template after September 6th 2019. They must comply with the structure which is described [here](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/doc/pipeline/build-tools.md#sap-cloud-application-programming-model--mta). + +If your project uses SAP HANA containers (HDI), you'll need to configure `createHdiContainer` and `cloudFoundry` in the `backendIntegrationTests` stage in your `pipeline_config.yml` file as documented [here](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/configuration.md#backendintegrationtests) + +Now, you'll need to push the code to a git repository. +This is required because the pipeline gets your code via git. +This might be GitHub, or any other cloud or on-premise git solution you have in your company. + +Be sure to configure the [`productionDeployment `](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/configuration.md#productiondeployment) stage so your changes are deployed to SAP Cloud Platform automatically. + +## Legacy documentation + +If your project is not based on the _SAP Cloud Platform Business Application_ WebIDE template, you could either migrate your code to comply with the structure which is described [here](https://github.com/SAP/cloud-s4-sdk-pipeline/blob/master/doc/pipeline/build-tools.md#sap-cloud-application-programming-model--mta), or you can use a self built pipeline, as described in this section. + +### Prerequisites + * You have an account on SAP Cloud Platform in the Cloud Foundry environment. See [Accounts](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/8ed4a705efa0431b910056c0acdbf377.html). * You have downloaded and installed the Cloud Foundry command line interface (CLI). See [Download and Install the Cloud Foundry Command Line Interface](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/afc3f643ec6942a283daad6cdf1b4936.html). * You have installed the multi-target application plug-in for the Cloud Foundry command line interface. See [Install the Multi-Target Application Plug-in in the Cloud Foundry Environment](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/27f3af39c2584d4ea8c15ba8c282fd75.html). @@ -13,15 +53,15 @@ Set up a basic continuous delivery process for developing applications according * You have installed the Multi-Target Application (MTA) Archive Builder 1.0.6 or newer. See [SAP Development Tools](https://tools.hana.ondemand.com/#cloud). * You have installed Node.js including node and npm. See [Node.js](https://nodejs.org/en/download/). -## Context +### Context The Application Programming Model for SAP Cloud Platform is an end-to-end best practice guide for developing applications on SAP Cloud Platform and provides a supportive set of APIs, languages, and libraries. For more information about the SAP Cloud Application Programming Model, see [Working with the SAP Cloud Application Programming Model](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/00823f91779d4d42aa29a498e0535cdf.html). In this scenario, we want to show how to implement a basic continuous delivery process for developing applications according to this programming model with the help of project "Piper" on Jenkins. This basic scenario can be adapted and enriched according to your specific needs. -## Example +### Example -### Jenkinsfile +#### Jenkinsfile ```groovy @Library('piper-library-os') _ @@ -43,7 +83,7 @@ node(){ } ``` -### Configuration (`.pipeline/config.yml`) +#### Configuration (`.pipeline/config.yml`) ```yaml steps: @@ -57,7 +97,7 @@ steps: space: '' ``` -### Parameters +#### Parameters For the detailed description of the relevant parameters, see: diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 3a0d0857c..3cadd2158 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -1,8 +1,31 @@ -site_name: Jenkins 2.0 Pipelines +site_name: 'Project "Piper": Continuous Delivery for the SAP Ecosystem' nav: - Home: index.md - 'Guided Tour' : guidedtour.md - Configuration: configuration.md + - 'Pipelines': + - 'General purpose pipeline': + - 'Introduction': stages/introduction.md + - 'Examples': stages/examples.md + - 'Stages': + - 'Init Stage': stages/init.md + - 'Pull-Request Voting Stage': stages/prvoting.md + - 'Build Stage': stages/build.md + - 'Additional Unit Test Stage': stages/additionalunittests.md + - 'Integration Stage': stages/integration.md + - 'Acceptance Stage': stages/acceptance.md + - 'Security Stage': stages/security.md + - 'Performance Stage': stages/performance.md + - 'Compliance': stages/compliance.md + - 'Confirm Stage': stages/confirm.md + - 'Promote Stage': stages/promote.md + - 'Release Stage': stages/release.md + - 'SAP Cloud SDK pipeline': pipelines/cloud-sdk/introduction.md + - 'Scenarios': + - 'Build and Deploy Hybrid Applications with Jenkins and SAP Solution Manager': scenarios/changeManagement.md + - 'Build and Deploy SAP UI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins': scenarios/ui5-sap-cp/Readme.md + - 'Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model': scenarios/CAP_Scenario.md + - 'Integrate SAP Cloud Platform Transport Management Into Your CI/CD Pipeline': scenarios/TMS_Extension.md - Extensibility: extensibility.md - 'Library steps': - artifactSetVersion: steps/artifactSetVersion.md @@ -53,28 +76,6 @@ nav: - uiVeri5ExecuteTests: steps/uiVeri5ExecuteTests.md - whitesourceExecuteScan: steps/whitesourceExecuteScan.md - xsDeploy: steps/xsDeploy.md - - 'Pipelines': - - 'General purpose pipeline': - - 'Introduction': stages/introduction.md - - 'Examples': stages/examples.md - - 'Stages': - - 'Init Stage': stages/init.md - - 'Pull-Request Voting Stage': stages/prvoting.md - - 'Build Stage': stages/build.md - - 'Additional Unit Test Stage': stages/additionalunittests.md - - 'Integration Stage': stages/integration.md - - 'Acceptance Stage': stages/acceptance.md - - 'Security Stage': stages/security.md - - 'Performance Stage': stages/performance.md - - 'Compliance': stages/compliance.md - - 'Confirm Stage': stages/confirm.md - - 'Promote Stage': stages/promote.md - - 'Release Stage': stages/release.md - - 'Scenarios': - - 'Build and Deploy Hybrid Applications with Jenkins and SAP Solution Manager': scenarios/changeManagement.md - - 'Build and Deploy SAP UI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins': scenarios/ui5-sap-cp/Readme.md - - 'Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model': scenarios/CAP_Scenario.md - - 'Integrate SAP Cloud Platform Transport Management Into Your CI/CD Pipeline': scenarios/TMS_Extension.md - Resources: - 'Required Plugins': jenkins/requiredPlugins.md From 05301eaf1687511fb39598c67838697a44c25860 Mon Sep 17 00:00:00 2001 From: Shanuson <54803480+Shanuson@users.noreply.github.com> Date: Fri, 20 Sep 2019 09:57:28 +0200 Subject: [PATCH 053/141] Refactoring Only PR for cloudFoundryDeployStep (#881) --- test/groovy/CloudFoundryDeployTest.groovy | 34 ++- vars/cloudFoundryDeploy.groovy | 240 +++++++++++----------- 2 files changed, 157 insertions(+), 117 deletions(-) diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index d83d949df..e8237f928 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -96,6 +96,7 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) // asserts assertThat(loggingRule.log, containsString('[cloudFoundryDeploy] General parameters: deployTool=, deployType=standard, cfApiEndpoint=https://api.cf.eu10.hana.ondemand.com, cfOrg=testOrg, cfSpace=testSpace, cfCredentialsId=myCreds')) + assertThat(loggingRule.log, containsString('[cloudFoundryDeploy] WARNING! Found unsupported deployTool. Skipping deployment.')) } @Test @@ -125,6 +126,7 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) // asserts assertThat(loggingRule.log, containsString('[cloudFoundryDeploy] General parameters: deployTool=notAvailable, deployType=standard, cfApiEndpoint=https://api.cf.eu10.hana.ondemand.com, cfOrg=testOrg, cfSpace=testSpace, cfCredentialsId=myCreds')) + assertThat(loggingRule.log, containsString('[cloudFoundryDeploy] WARNING! Found unsupported deployTool. Skipping deployment.')) } @Test @@ -351,7 +353,7 @@ class CloudFoundryDeployTest extends BasePiperTest { readYamlRule.registerYaml('test.yml', "applications: [[]]") thrown.expect(hudson.AbortException) - thrown.expectMessage("Could not stop application testAppName-old. Error: any error message") + thrown.expectMessage("[cloudFoundryDeploy] ERROR: Could not stop application testAppName-old. Error: any error message") stepRule.step.cloudFoundryDeploy([ script: nullScript, @@ -419,6 +421,36 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) } + @Test + void testCfNativeFailureInShellCall() { + readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX,/(cf login -u "test_cf")/,1) + + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryDeploy] ERROR: The execution of the deploy command failed, see the log for details.') + + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml' + ]) + + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + @Test void testMta() { diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index af5232fb5..fb8adfc31 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -157,49 +157,26 @@ void call(Map parameters = [:]) { //make sure that for further execution whole workspace, e.g. also downloaded artifacts are considered config.stashContent = [] - boolean deploy = false + boolean deployTriggered = false boolean deploySuccess = true try { if (config.deployTool == 'mtaDeployPlugin') { - deploy = true - // set default mtar path - config = ConfigurationHelper.newInstance(this, config) - .addIfEmpty('mtaPath', config.mtaPath?:findMtar()) - .use() - - dockerExecute(script: script, dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent) { - deployMta(config) - } + deployTriggered = true + handleMTADeployment(config, script) } - - if (config.deployTool == 'cf_native') { - deploy = true - config.smokeTest = '' - - if (config.smokeTestScript == 'blueGreenCheckScript.sh') { - writeFile file: config.smokeTestScript, text: libraryResource(config.smokeTestScript) - } - - config.smokeTest = '--smoke-test $(pwd)/' + config.smokeTestScript - sh "chmod +x ${config.smokeTestScript}" - - echo "[${STEP_NAME}] CF native deployment (${config.deployType}) with cfAppName=${config.cloudFoundry.appName}, cfManifest=${config.cloudFoundry.manifest}, smokeTestScript=${config.smokeTestScript}" - - dockerExecute ( - script: script, - dockerImage: config.dockerImage, - dockerWorkspace: config.dockerWorkspace, - stashContent: config.stashContent, - dockerEnvVars: [CF_HOME:"${config.dockerWorkspace}", CF_PLUGIN_HOME:"${config.dockerWorkspace}", STATUS_CODE: "${config.smokeTestStatusCode}"] - ) { - deployCfNative(config) - } + else if (config.deployTool == 'cf_native') { + deployTriggered = true + handleCFNativeDeployment(config, script) + } + else { + deployTriggered = false + echo "[${STEP_NAME}] WARNING! Found unsupported deployTool. Skipping deployment." } } catch (err) { deploySuccess = false throw err } finally { - if (deploy) { + if (deployTriggered) { reportToInflux(script, config, deploySuccess, jenkinsUtils) } @@ -208,6 +185,17 @@ void call(Map parameters = [:]) { } } +private void handleMTADeployment(Map config, script) { + // set default mtar path + config = ConfigurationHelper.newInstance(this, config) + .addIfEmpty('mtaPath', config.mtaPath ?: findMtar()) + .use() + + dockerExecute(script: script, dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent) { + deployMta(config) + } +} + def findMtar(){ def mtarFiles = findFiles(glob: '**/*.mtar') @@ -220,87 +208,6 @@ def findMtar(){ error 'No *.mtar file found!' } -def deployCfNative (config) { - withCredentials([usernamePassword( - credentialsId: config.cloudFoundry.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username' - )]) { - def deployCommand = selectCfDeployCommandForDeployType(config) - - if (config.deployType == 'blue-green') { - handleLegacyCfManifest(config) - } else { - config.smokeTest = '' - } - - def blueGreenDeployOptions = deleteOptionIfRequired(config) - - // check if appName is available - if (config.cloudFoundry.appName == null || config.cloudFoundry.appName == '') { - if (config.deployType == 'blue-green') { - error "[${STEP_NAME}] ERROR: Blue-green plugin requires app name to be passed (see https://github.com/bluemixgaragelondon/cf-blue-green-deploy/issues/27)" - } - if (fileExists(config.cloudFoundry.manifest)) { - def manifest = readYaml file: config.cloudFoundry.manifest - if (!manifest || !manifest.applications || !manifest.applications[0].name) - error "[${STEP_NAME}] ERROR: No appName available in manifest ${config.cloudFoundry.manifest}." - - } else { - error "[${STEP_NAME}] ERROR: No manifest file ${config.cloudFoundry.manifest} found." - } - } - - def returnCode = sh returnStatus: true, script: """#!/bin/bash - set +x - set -e - export HOME=${config.dockerWorkspace} - cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" - cf plugins - cf ${deployCommand} ${config.cloudFoundry.appName ?: ''} ${blueGreenDeployOptions} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} - """ - if(returnCode != 0){ - error "[ERROR][${STEP_NAME}] The execution of the deploy command failed, see the log for details." - } - stopOldAppIfRunning(config) - sh "cf logout" - } -} - -private String selectCfDeployCommandForDeployType(Map config) { - if (config.deployType == 'blue-green') { - return 'blue-green-deploy' - } else { - return 'push' - } -} - -private String deleteOptionIfRequired(Map config) { - boolean deleteOldInstance = !config.keepOldInstance - if (deleteOldInstance && config.deployType == 'blue-green') { - return '--delete-old-apps' - } else { - return '' - } -} - -private void stopOldAppIfRunning(Map config) { - String oldAppName = "${config.cloudFoundry.appName}-old" - String cfStopOutputFileName = "${UUID.randomUUID()}-cfStopOutput.txt" - - if (config.keepOldInstance && config.deployType == 'blue-green') { - int cfStopReturncode = sh (returnStatus: true, script: "cf stop $oldAppName &> $cfStopOutputFileName") - - if (cfStopReturncode > 0) { - String cfStopOutput = readFile(file: cfStopOutputFileName) - - if (!cfStopOutput.contains("$oldAppName not found")) { - error "Could not stop application $oldAppName. Error: $cfStopOutput" - } - } - } -} - def deployMta (config) { if (config.mtaExtensionDescriptor == null) config.mtaExtensionDescriptor = '' if (!config.mtaExtensionDescriptor.isEmpty() && !config.mtaExtensionDescriptor.startsWith('-e ')) config.mtaExtensionDescriptor = "-e ${config.mtaExtensionDescriptor}" @@ -328,12 +235,53 @@ def deployMta (config) { cf plugins cf ${deployCommand} ${config.mtaPath} ${config.mtaDeployParameters} ${config.mtaExtensionDescriptor}""" if(returnCode != 0){ - error "[ERROR][${STEP_NAME}] The execution of the deploy command failed, see the log for details." + error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." } sh "cf logout" } } +private void handleCFNativeDeployment(Map config, script) { + config.smokeTest = '' + + if (config.deployType == 'blue-green') { + prepareBlueGreenCfNativeDeploy(config) + } else { + prepareCfPushCfNativeDeploy(config) + } + + echo "[${STEP_NAME}] CF native deployment (${config.deployType}) with:" + echo "[${STEP_NAME}] - cfAppName=${config.cloudFoundry.appName}" + echo "[${STEP_NAME}] - cfManifest=${config.cloudFoundry.manifest}" + echo "[${STEP_NAME}] - smokeTestScript=${config.smokeTestScript}" + + checkIfAppNameIsAvailable(config) + dockerExecute( + script: script, + dockerImage: config.dockerImage, + dockerWorkspace: config.dockerWorkspace, + stashContent: config.stashContent, + dockerEnvVars: [CF_HOME: "${config.dockerWorkspace}", CF_PLUGIN_HOME: "${config.dockerWorkspace}", STATUS_CODE: "${config.smokeTestStatusCode}"] + ) { + deployCfNative(config) + } +} + +private prepareBlueGreenCfNativeDeploy(config) { + if (config.smokeTestScript == 'blueGreenCheckScript.sh') { + writeFile file: config.smokeTestScript, text: libraryResource(config.smokeTestScript) + } + + config.smokeTest = '--smoke-test $(pwd)/' + config.smokeTestScript + sh "chmod +x ${config.smokeTestScript}" + + config.deployCommand = 'blue-green-deploy' + handleLegacyCfManifest(config) + if (!config.keepOldInstance) { + config.deployOptions = '--delete-old-apps' + } +} + def handleLegacyCfManifest(config) { def manifest = readYaml file: config.cloudFoundry.manifest String originalManifest = manifest.toString() @@ -349,6 +297,66 @@ Transformed manifest file content: $transformedManifest""" } } +private prepareCfPushCfNativeDeploy(config) { + config.deployCommand = 'push' + config.deployOptions = '' +} + +private checkIfAppNameIsAvailable(config) { + if (config.cloudFoundry.appName == null || config.cloudFoundry.appName == '') { + if (config.deployType == 'blue-green') { + error "[${STEP_NAME}] ERROR: Blue-green plugin requires app name to be passed (see https://github.com/bluemixgaragelondon/cf-blue-green-deploy/issues/27)" + } + if (fileExists(config.cloudFoundry.manifest)) { + def manifest = readYaml file: config.cloudFoundry.manifest + if (!manifest || !manifest.applications || !manifest.applications[0].name) { + error "[${STEP_NAME}] ERROR: No appName available in manifest ${config.cloudFoundry.manifest}." + } + } else { + error "[${STEP_NAME}] ERROR: No manifest file ${config.cloudFoundry.manifest} found." + } + } +} + +def deployCfNative (config) { + withCredentials([usernamePassword( + credentialsId: config.cloudFoundry.credentialsId, + passwordVariable: 'password', + usernameVariable: 'username' + )]) { + def returnCode = sh returnStatus: true, script: """#!/bin/bash + set +x + set -e + export HOME=${config.dockerWorkspace} + cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" + cf plugins + cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} + """ + + if(returnCode != 0){ + error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." + } + stopOldAppIfRunning(config) + sh "cf logout" + } +} + +private void stopOldAppIfRunning(Map config) { + String oldAppName = "${config.cloudFoundry.appName}-old" + String cfStopOutputFileName = "${UUID.randomUUID()}-cfStopOutput.txt" + + if (config.keepOldInstance && config.deployType == 'blue-green') { + int cfStopReturncode = sh (returnStatus: true, script: "cf stop $oldAppName &> $cfStopOutputFileName") + + if (cfStopReturncode > 0) { + String cfStopOutput = readFile(file: cfStopOutputFileName) + + if (!cfStopOutput.contains("$oldAppName not found")) { + error "[${STEP_NAME}] ERROR: Could not stop application $oldAppName. Error: $cfStopOutput" + } + } + } +} private void reportToInflux(script, config, deploySuccess, JenkinsUtils jenkinsUtils) { def deployUser = '' From 6dabdff8d510608170dd8f17d91f342cdec463ab Mon Sep 17 00:00:00 2001 From: TheFonz2017 <31238517+TheFonz2017@users.noreply.github.com> Date: Tue, 24 Sep 2019 08:49:25 +0200 Subject: [PATCH 054/141] Added (optional) Variable Substitution to CloudFoundryDeploy Step (#866) --- test/groovy/CloudFoundryDeployTest.groovy | 258 +++++++++++++++++- test/groovy/util/JenkinsFileExistsRule.groovy | 4 + vars/cloudFoundryDeploy.groovy | 84 +++++- 3 files changed, 338 insertions(+), 8 deletions(-) diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index e8237f928..26adaf1b0 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -9,6 +9,7 @@ import util.BasePiperTest import util.JenkinsCredentialsRule import util.JenkinsEnvironmentRule import util.JenkinsDockerExecuteRule +import util.JenkinsFileExistsRule import util.JenkinsLoggingRule import util.JenkinsReadFileRule import util.JenkinsShellCallRule @@ -18,7 +19,7 @@ import util.JenkinsReadYamlRule import util.Rules import static org.hamcrest.Matchers.stringContainsInOrder -import static org.junit.Assert.assertThat +import static org.junit.Assert.* import static org.hamcrest.Matchers.hasItem import static org.hamcrest.Matchers.is @@ -38,6 +39,7 @@ class CloudFoundryDeployTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this) + private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this) private writeInfluxMap = [:] @@ -56,6 +58,7 @@ class CloudFoundryDeployTest extends BasePiperTest { .around(shellRule) .around(writeFileRule) .around(readFileRule) + .around(fileExistsRule) .around(dockerExecuteRule) .around(environmentRule) .around(new JenkinsCredentialsRule(this).withCredentials('test_cfCredentialsId', 'test_cf', '********')) @@ -210,7 +213,7 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeAppNameFromManifest() { - helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) + fileExistsRule.registerExistingFile('test.yml') readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> generatedFile = parameters.file @@ -235,7 +238,7 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeWithoutAppName() { - helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) + fileExistsRule.registerExistingFile('test.yml') readYamlRule.registerYaml('test.yml', "applications: [[]]") helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> generatedFile = parameters.file @@ -402,7 +405,7 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfNativeWithoutAppNameBlueGreen() { - helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) + fileExistsRule.registerExistingFile('test.yml') readYamlRule.registerYaml('test.yml', "applications: [[]]") thrown.expect(hudson.AbortException) @@ -522,4 +525,251 @@ class CloudFoundryDeployTest extends BasePiperTest { assertThat(writeInfluxMap.customDataMapTags.deployment_data.cfSpace, is('testSpace')) } + @Test + void testCfPushDeploymentWithVariableSubstitutionFromFile() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + fileExistsRule.registerExistingFile('test.yml') + fileExistsRule.registerExistingFile('vars.yml') + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariablesFiles: ['vars.yml'] + ]) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName --vars-file 'vars.yml' -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + assertThat(loggingRule.log,containsString("We will add the following string to the cf push call: --vars-file 'vars.yml' !")) + assertThat(loggingRule.log,not(containsString("We will add the following string to the cf push call: !"))) + } + + @Test + void testCfPushDeploymentWithVariableSubstitutionFromNotExistingFilePrintsWarning() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + fileExistsRule.registerExistingFile('test.yml') + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariablesFiles: ['vars.yml'] + ]) + + // asserts + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'test.yml'"))) + assertThat(loggingRule.log, containsString("[WARNING] We skip adding not-existing file 'vars.yml' as a vars-file to the cf create-service-push call")) + } + + @Test + void testCfPushDeploymentWithVariableSubstitutionFromVarsList() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + List varsList = [["appName" : "testApplicationFromVarsList"]] + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariables: varsList + ]) + + // asserts + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName --var appName='testApplicationFromVarsList' -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + assertThat(loggingRule.log,containsString("We will add the following string to the cf push call: --var appName='testApplicationFromVarsList' !")) + assertThat(loggingRule.log,not(containsString("We will add the following string to the cf push call: !"))) + } + + @Test + void testCfPushDeploymentWithVariableSubstitutionFromVarsListNotAList() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryDeploy] ERROR: Parameter config.cloudFoundry.manifestVariables is not a List!') + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariables: 'notAList' + ]) + + } + + @Test + void testCfPushDeploymentWithVariableSubstitutionFromVarsListAndVarsFile() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + List varsList = [["appName" : "testApplicationFromVarsList"]] + fileExistsRule.registerExistingFile('vars.yml') + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariablesFiles: ['vars.yml'], + cfManifestVariables: varsList + ]) + + // asserts + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName --var appName='testApplicationFromVarsList' --vars-file 'vars.yml' -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + + @Test + void testCfPushDeploymentWithoutVariableSubstitution() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml' + ]) + + // asserts + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + + @Test + void testCfBlueGreenDeploymentWithVariableSubstitution() { + + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + readYamlRule.registerYaml('vars.yml', "[appName: 'testApplication']") + + fileExistsRule.registerExistingFile("test.yml") + fileExistsRule.registerExistingFile("vars.yml") + + boolean testYamlWritten = false + def testYamlData = null + helper.registerAllowedMethod('writeYaml', [Map], { Map m -> + if (m.file.equals("test.yml")) { + testYamlWritten = true + testYamlData = m.data + } + }) + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + deployType: 'blue-green', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariablesFiles: ['vars.yml'] + ]) + + // asserts + assertTrue(testYamlWritten) + assertNotNull(testYamlData) + assertThat(testYamlData.get("applications").get(0).get(0).get("name"), is("testApplication")) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf blue-green-deploy testAppName --delete-old-apps -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + + @Test + void testCfBlueGreenDeploymentWithVariableSubstitutionFromVarsList() { + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + readYamlRule.registerYaml('vars.yml', "[appName: 'testApplication']") + List varsList = [["appName" : "testApplicationFromVarsList"]] + + fileExistsRule.registerExistingFile("test.yml") + fileExistsRule.registerExistingFile("vars.yml") + + boolean testYamlWritten = false + def testYamlData = null + helper.registerAllowedMethod('writeYaml', [Map], { Map m -> + if (m.file.equals("test.yml")) { + testYamlWritten = true + testYamlData = m.data + } + }) + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + deployType: 'blue-green', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml', + cfManifestVariablesFiles: ['vars.yml'], + cfManifestVariables: varsList + ]) + + // asserts + assertTrue(testYamlWritten) + assertNotNull(testYamlData) + assertThat(testYamlData.get("applications").get(0).get(0).get("name"), is("testApplicationFromVarsList")) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString("cf blue-green-deploy testAppName --delete-old-apps -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } } diff --git a/test/groovy/util/JenkinsFileExistsRule.groovy b/test/groovy/util/JenkinsFileExistsRule.groovy index cc01c48cc..d5b18c919 100644 --- a/test/groovy/util/JenkinsFileExistsRule.groovy +++ b/test/groovy/util/JenkinsFileExistsRule.groovy @@ -15,6 +15,10 @@ class JenkinsFileExistsRule implements TestRule { */ final List queriedFiles = [] + JenkinsFileExistsRule(BasePipelineTest testInstance) { + this(testInstance,[]) + } + JenkinsFileExistsRule(BasePipelineTest testInstance, List existingFiles) { this.testInstance = testInstance this.existingFiles = existingFiles diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index fb8adfc31..2690e70f9 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -6,6 +6,7 @@ import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper import com.sap.piper.CfManifestUtils +import com.sap.piper.BashUtils import groovy.transform.Field @@ -35,6 +36,35 @@ import groovy.transform.Field * @parentConfigKey cloudFoundry */ 'manifest', + /** + * Defines the manifest variables Yaml files to be used to replace variable references in manifest. This parameter + * is optional and will default to `["manifest-variables.yml"]`. This can be used to set variable files like it + * is provided by `cf push --vars-file `. + * + * If the manifest is present and so are all variable files, a variable substitution will be triggered that uses + * the `cfManifestSubstituteVariables` step before deployment. The format of variable references follows the + * [Cloud Foundry standard](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#variable-substitution). + * @parentConfigKey cloudFoundry + */ + 'manifestVariablesFiles', + /** + * Defines a `List` of variables as key-value `Map` objects used for variable substitution within the file given by `manifest`. + * Defaults to an empty list, if not specified otherwise. This can be used to set variables like it is provided + * by `cf push --var key=value`. + * + * The order of the maps of variables given in the list is relevant in case there are conflicting variable names and values + * between maps contained within the list. In case of conflicts, the last specified map in the list will win. + * + * Though each map entry in the list can contain more than one key-value pair for variable substitution, it is recommended + * to stick to one entry per map, and rather declare more maps within the list. The reason is that + * if a map in the list contains more than one key-value entry, and the entries are conflicting, the + * conflict resolution behavior is undefined (since map entries have no sequence). + * + * Note: variables defined via `manifestVariables` always win over conflicting variables defined via any file given + * by `manifestVariablesFiles` - no matter what is declared before. This is the same behavior as can be + * observed when using `cf push --var` in combination with `cf push --vars-file`. + */ + 'manifestVariables', /** * Cloud Foundry target organization. * @parentConfigKey cloudFoundry @@ -89,7 +119,7 @@ import groovy.transform.Field 'smokeTestStatusCode' ] -@Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', org: 'cfOrg', space: 'cfSpace']] +@Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', manifestVariablesFiles: 'cfManifestVariablesFiles', manifestVariables: 'cfManifestVariables', org: 'cfOrg', space: 'cfSpace']] @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS @@ -245,7 +275,7 @@ private void handleCFNativeDeployment(Map config, script) { config.smokeTest = '' if (config.deployType == 'blue-green') { - prepareBlueGreenCfNativeDeploy(config) + prepareBlueGreenCfNativeDeploy(config,script) } else { prepareCfPushCfNativeDeploy(config) } @@ -253,6 +283,8 @@ private void handleCFNativeDeployment(Map config, script) { echo "[${STEP_NAME}] CF native deployment (${config.deployType}) with:" echo "[${STEP_NAME}] - cfAppName=${config.cloudFoundry.appName}" echo "[${STEP_NAME}] - cfManifest=${config.cloudFoundry.manifest}" + echo "[${STEP_NAME}] - cfManifestVariables=${config.cloudFoundry.manifestVariables?:'none specified'}" + echo "[${STEP_NAME}] - cfManifestVariablesFiles=${config.cloudFoundry.manifestVariablesFiles?:'none specified'}" echo "[${STEP_NAME}] - smokeTestScript=${config.smokeTestScript}" checkIfAppNameIsAvailable(config) @@ -267,7 +299,7 @@ private void handleCFNativeDeployment(Map config, script) { } } -private prepareBlueGreenCfNativeDeploy(config) { +private prepareBlueGreenCfNativeDeploy(config,script) { if (config.smokeTestScript == 'blueGreenCheckScript.sh') { writeFile file: config.smokeTestScript, text: libraryResource(config.smokeTestScript) } @@ -276,6 +308,12 @@ private prepareBlueGreenCfNativeDeploy(config) { sh "chmod +x ${config.smokeTestScript}" config.deployCommand = 'blue-green-deploy' + cfManifestSubstituteVariables( + script: script, + manifestFile: config.cloudFoundry.manifest, + manifestVariablesFiles: config.cloudFoundry.manifestVariablesFiles, + manifestVariables: config.cloudFoundry.manifestVariables + ) handleLegacyCfManifest(config) if (!config.keepOldInstance) { config.deployOptions = '--delete-old-apps' @@ -299,7 +337,45 @@ Transformed manifest file content: $transformedManifest""" private prepareCfPushCfNativeDeploy(config) { config.deployCommand = 'push' - config.deployOptions = '' + config.deployOptions = "${varOptions(config)}${varFileOptions(config)}" +} + +private varOptions(Map config) { + String varPart = '' + if (config.cloudFoundry.manifestVariables) { + if (!(config.cloudFoundry.manifestVariables in List)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariables is not a List!" + } + config.cloudFoundry.manifestVariables.each { + if (!(it in Map)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariables.$it is not a Map!" + } + it.keySet().each { varKey -> + String varValue=BashUtils.quoteAndEscape(it.get(varKey).toString()) + varPart += " --var $varKey=$varValue" + } + } + } + if (varPart) echo "We will add the following string to the cf push call:$varPart !" + return varPart +} + +private String varFileOptions(Map config) { + String varFilePart = '' + if (config.cloudFoundry.manifestVariablesFiles) { + if (!(config.cloudFoundry.manifestVariablesFiles in List)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariablesFiles is not a List!" + } + config.cloudFoundry.manifestVariablesFiles.each { + if (fileExists(it)) { + varFilePart += " --vars-file ${BashUtils.quoteAndEscape(it)}" + } else { + echo "[${STEP_NAME}] [WARNING] We skip adding not-existing file '$it' as a vars-file to the cf create-service-push call" + } + } + } + if (varFilePart) echo "We will add the following string to the cf push call:$varFilePart !" + return varFilePart } private checkIfAppNameIsAvailable(config) { From 96365d29f7727f1c83130e992871b725c8e19154 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 25 Sep 2019 08:55:51 +0200 Subject: [PATCH 055/141] Add buildExecute to docu index. (#860) --- documentation/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 3cadd2158..8ad923cce 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -30,6 +30,7 @@ nav: - 'Library steps': - artifactSetVersion: steps/artifactSetVersion.md - batsExecuteTests: steps/batsExecuteTests.md + - buildExecute: steps/buildExecute.md - checkChangeInDevelopment: steps/checkChangeInDevelopment.md - checksPublishResults: steps/checksPublishResults.md - cfManifestSubstituteVariables: steps/cfManifestSubstituteVariables.md From 61a91b5f253d22667f43bd6c31a68873aa238b67 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 25 Sep 2019 11:00:35 +0200 Subject: [PATCH 056/141] Add containerPushToRegistry to docu index (#862) --- documentation/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 8ad923cce..7b63bf062 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -37,6 +37,7 @@ nav: - cloudFoundryDeploy: steps/cloudFoundryDeploy.md - commonPipelineEnvironment: steps/commonPipelineEnvironment.md - containerExecuteStructureTests: steps/containerExecuteStructureTests.md + - containerPushToRegistry: steps/containerPushToRegistry.md - detectExecuteScan: steps/detectExecuteScan.md - dockerExecute: steps/dockerExecute.md - dockerExecuteOnKubernetes: steps/dockerExecuteOnKubernetes.md From eb57c8df7b9a4b7a9f44ae57f5054303a0c9f74e Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 26 Sep 2019 12:23:36 +0200 Subject: [PATCH 057/141] Back commonPipelineEnvironment step by shared class (#821) * Back commonPipelineEnvironment step by shared class Each pipeline step comes with its own instance of a commonPipelineEnvironment. Properties stored on one instance was not shared with the other instances. Now we strip down the commonPipelineEnvironment step and forward basically everything to a shared singleton instance. With that approach all instances of commonPipelineEnvironment shares the same data and can now be really used for information exchange between the steps. Before that change only the commonPipelineEnvironment instance associated with the pipeline script itself could be used for that purpose. * Remove unneeded commented line --- .../piper/CommonPipelineEnvironment.groovy | 154 ++++++++++++++++++ .../util/JenkinsResetDefaultCacheRule.groovy | 2 + vars/commonPipelineEnvironment.groovy | 148 ++--------------- 3 files changed, 171 insertions(+), 133 deletions(-) create mode 100644 src/com/sap/piper/CommonPipelineEnvironment.groovy diff --git a/src/com/sap/piper/CommonPipelineEnvironment.groovy b/src/com/sap/piper/CommonPipelineEnvironment.groovy new file mode 100644 index 000000000..655c245c8 --- /dev/null +++ b/src/com/sap/piper/CommonPipelineEnvironment.groovy @@ -0,0 +1,154 @@ +package com.sap.piper; + +import com.sap.piper.analytics.InfluxData + +public class CommonPipelineEnvironment { + + private static CommonPipelineEnvironment INSTANCE = new CommonPipelineEnvironment() + + static CommonPipelineEnvironment getInstance() { + INSTANCE + } + + Map defaultConfiguration = [:] + + // The project config + Map configuration = [:] + + private Map valueMap = [:] + + //stores properties for a pipeline which build an artifact and then bundles it into a container + private Map appContainerProperties = [:] + + //stores version of the artifact which is build during pipeline run + def artifactVersion + + //Stores the current buildResult + String buildResult = 'SUCCESS' + + //stores the gitCommitId as well as additional git information for the build during pipeline run + String gitCommitId + String gitCommitMessage + String gitSshUrl + String gitHttpsUrl + String gitBranch + + //GiutHub specific information + String githubOrg + String githubRepo + + String mtarFilePath + + String changeDocumentId + + String xsDeploymentId + + void setValue(String property, value) { + valueMap[property] = value + } + + def getValue(String property) { + return valueMap.get(property) + } + + def setAppContainerProperty(property, value) { + appContainerProperties[property] = value + } + + def getAppContainerProperty(property) { + return appContainerProperties[property] + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataEntry(key, value) { + InfluxData.addField('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomData() { + return InfluxData.getInstance().getFields().jenkins_custom_data + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataTagsEntry(key, value) { + InfluxData.addTag('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomDataTags() { + return InfluxData.getInstance().getTags().jenkins_custom_data + } + + void setInfluxCustomDataMapEntry(measurement, field, value) { + InfluxData.addField(measurement, field, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMap() { + return InfluxData.getInstance().getFields() + } + + def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { + InfluxData.addTag(measurement, tag, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMapTags() { + return InfluxData.getInstance().getTags() + } + + @Deprecated // not used in library + def setInfluxStepData(key, value) { + InfluxData.addField('step_data', key, value) + } + @Deprecated // not used in library + def getInfluxStepData(key) { + return InfluxData.getInstance().getFields()['step_data'][key] + } + + @Deprecated // not used in library + def setInfluxPipelineData(key, value) { + InfluxData.addField('pipeline_data', key, value) + } + @Deprecated // not used in library + def setPipelineMeasurement(key, value){ + setInfluxPipelineData(key, value) + } + @Deprecated // not used in library + def getPipelineMeasurement(key) { + return InfluxData.getInstance().getFields()['pipeline_data'][key] + } + + def reset() { + appContainerProperties = [:] + configuration = [:] + artifactVersion = null + + gitCommitId = null + gitCommitMessage = null + gitSshUrl = null + gitHttpsUrl = null + gitBranch = null + + githubOrg = null + githubRepo = null + + mtarFilePath = null + valueMap = [:] + + changeDocumentId = null + + InfluxData.reset() + } + + Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { + Map defaults = [:] + if (includeDefaults) { + defaults = DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration([commonPipelineEnvironment: this], stepName), null, defaults) + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration([commonPipelineEnvironment: this], stageName), null, defaults) + } + Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) + config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) + config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) + return config + } +} diff --git a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy index 680e2fc93..301c29b13 100644 --- a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy +++ b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy @@ -6,6 +6,7 @@ import org.junit.runners.model.Statement import com.lesfurets.jenkins.unit.BasePipelineTest import com.sap.piper.DefaultValueCache +import com.sap.piper.CommonPipelineEnvironment class JenkinsResetDefaultCacheRule implements TestRule { @@ -27,6 +28,7 @@ class JenkinsResetDefaultCacheRule implements TestRule { @Override void evaluate() throws Throwable { DefaultValueCache.reset() + CommonPipelineEnvironment.getInstance().reset() base.evaluate() } } diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index a0ec24150..49b12f0b5 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -1,146 +1,28 @@ import com.sap.piper.ConfigurationLoader import com.sap.piper.ConfigurationMerger +import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.analytics.InfluxData class commonPipelineEnvironment implements Serializable { - //stores version of the artifact which is build during pipeline run - def artifactVersion + // We forward everything to the singleton instance of + // commonPipelineEnvironment (CPE) on default value cache. + // + // Some background: each step has its own instance of CPE step. + // In case each instance has its own set of properties these instances + // are configured individually. Properties set on one instance cannot be + // retrieved with another instance. Now each instance forwards to one singleton. + // This means: all instances of the CPE shares the same properties/configuration. - //Stores the current buildResult - String buildResult = 'SUCCESS' - - //stores the gitCommitId as well as additional git information for the build during pipeline run - String gitCommitId - String gitCommitMessage - String gitSshUrl - String gitHttpsUrl - String gitBranch - - String xsDeploymentId - - //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 = [:] - - Map configuration = [:] - Map defaultConfiguration = [:] - - String mtarFilePath - private Map valueMap = [:] - - void setValue(String property, value) { - valueMap[property] = value + def methodMissing(String name, def args) { + CommonPipelineEnvironment.getInstance().invokeMethod(name, args) } - def getValue(String property) { - return valueMap.get(property) + def propertyMissing(def name) { + CommonPipelineEnvironment.getInstance()[name] } - String changeDocumentId - - def reset() { - appContainerProperties = [:] - artifactVersion = null - - configuration = [:] - - gitCommitId = null - gitCommitMessage = null - gitSshUrl = null - gitHttpsUrl = null - gitBranch = null - - githubOrg = null - githubRepo = null - - mtarFilePath = null - valueMap = [:] - - changeDocumentId = null - - InfluxData.reset() - } - - def setAppContainerProperty(property, value) { - appContainerProperties[property] = value - } - - def getAppContainerProperty(property) { - return appContainerProperties[property] - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataEntry(key, value) { - InfluxData.addField('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomData() { - return InfluxData.getInstance().getFields().jenkins_custom_data - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataTagsEntry(key, value) { - InfluxData.addTag('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomDataTags() { - return InfluxData.getInstance().getTags().jenkins_custom_data - } - - void setInfluxCustomDataMapEntry(measurement, field, value) { - InfluxData.addField(measurement, field, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMap() { - return InfluxData.getInstance().getFields() - } - - def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { - InfluxData.addTag(measurement, tag, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMapTags() { - return InfluxData.getInstance().getTags() - } - - @Deprecated // not used in library - def setInfluxStepData(key, value) { - InfluxData.addField('step_data', key, value) - } - @Deprecated // not used in library - def getInfluxStepData(key) { - return InfluxData.getInstance().getFields()['step_data'][key] - } - - @Deprecated // not used in library - def setInfluxPipelineData(key, value) { - InfluxData.addField('pipeline_data', key, value) - } - @Deprecated // not used in library - def setPipelineMeasurement(key, value){ - setInfluxPipelineData(key, value) - } - @Deprecated // not used in library - def getPipelineMeasurement(key) { - return InfluxData.getInstance().getFields()['pipeline_data'][key] - } - - Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { - Map defaults = [:] - if (includeDefaults) { - defaults = ConfigurationLoader.defaultGeneralConfiguration() - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration(null, stepName), null, defaults) - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration(null, stageName), null, defaults) - } - Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) - config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) - config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) - return config + def propertyMissing(def name, def value) { + CommonPipelineEnvironment.getInstance()[name] = value } } From e418c15b6e3627b5dc352689ad5271d9bffbeb21 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 26 Sep 2019 14:18:18 +0200 Subject: [PATCH 058/141] Revert "Back commonPipelineEnvironment step by shared class (#821)" (#885) This reverts commit eb57c8df7b9a4b7a9f44ae57f5054303a0c9f74e. --- .../piper/CommonPipelineEnvironment.groovy | 154 ------------------ .../util/JenkinsResetDefaultCacheRule.groovy | 2 - vars/commonPipelineEnvironment.groovy | 148 +++++++++++++++-- 3 files changed, 133 insertions(+), 171 deletions(-) delete mode 100644 src/com/sap/piper/CommonPipelineEnvironment.groovy diff --git a/src/com/sap/piper/CommonPipelineEnvironment.groovy b/src/com/sap/piper/CommonPipelineEnvironment.groovy deleted file mode 100644 index 655c245c8..000000000 --- a/src/com/sap/piper/CommonPipelineEnvironment.groovy +++ /dev/null @@ -1,154 +0,0 @@ -package com.sap.piper; - -import com.sap.piper.analytics.InfluxData - -public class CommonPipelineEnvironment { - - private static CommonPipelineEnvironment INSTANCE = new CommonPipelineEnvironment() - - static CommonPipelineEnvironment getInstance() { - INSTANCE - } - - Map defaultConfiguration = [:] - - // The project config - Map configuration = [:] - - private Map valueMap = [:] - - //stores properties for a pipeline which build an artifact and then bundles it into a container - private Map appContainerProperties = [:] - - //stores version of the artifact which is build during pipeline run - def artifactVersion - - //Stores the current buildResult - String buildResult = 'SUCCESS' - - //stores the gitCommitId as well as additional git information for the build during pipeline run - String gitCommitId - String gitCommitMessage - String gitSshUrl - String gitHttpsUrl - String gitBranch - - //GiutHub specific information - String githubOrg - String githubRepo - - String mtarFilePath - - String changeDocumentId - - String xsDeploymentId - - void setValue(String property, value) { - valueMap[property] = value - } - - def getValue(String property) { - return valueMap.get(property) - } - - def setAppContainerProperty(property, value) { - appContainerProperties[property] = value - } - - def getAppContainerProperty(property) { - return appContainerProperties[property] - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataEntry(key, value) { - InfluxData.addField('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomData() { - return InfluxData.getInstance().getFields().jenkins_custom_data - } - - // goes into measurement jenkins_custom_data - def setInfluxCustomDataTagsEntry(key, value) { - InfluxData.addTag('jenkins_custom_data', key, value) - } - // goes into measurement jenkins_custom_data - @Deprecated // not used in library - def getInfluxCustomDataTags() { - return InfluxData.getInstance().getTags().jenkins_custom_data - } - - void setInfluxCustomDataMapEntry(measurement, field, value) { - InfluxData.addField(measurement, field, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMap() { - return InfluxData.getInstance().getFields() - } - - def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { - InfluxData.addTag(measurement, tag, value) - } - @Deprecated // not used in library - def getInfluxCustomDataMapTags() { - return InfluxData.getInstance().getTags() - } - - @Deprecated // not used in library - def setInfluxStepData(key, value) { - InfluxData.addField('step_data', key, value) - } - @Deprecated // not used in library - def getInfluxStepData(key) { - return InfluxData.getInstance().getFields()['step_data'][key] - } - - @Deprecated // not used in library - def setInfluxPipelineData(key, value) { - InfluxData.addField('pipeline_data', key, value) - } - @Deprecated // not used in library - def setPipelineMeasurement(key, value){ - setInfluxPipelineData(key, value) - } - @Deprecated // not used in library - def getPipelineMeasurement(key) { - return InfluxData.getInstance().getFields()['pipeline_data'][key] - } - - def reset() { - appContainerProperties = [:] - configuration = [:] - artifactVersion = null - - gitCommitId = null - gitCommitMessage = null - gitSshUrl = null - gitHttpsUrl = null - gitBranch = null - - githubOrg = null - githubRepo = null - - mtarFilePath = null - valueMap = [:] - - changeDocumentId = null - - InfluxData.reset() - } - - Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { - Map defaults = [:] - if (includeDefaults) { - defaults = DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration([commonPipelineEnvironment: this], stepName), null, defaults) - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration([commonPipelineEnvironment: this], stageName), null, defaults) - } - Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) - config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) - config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) - return config - } -} diff --git a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy index 301c29b13..680e2fc93 100644 --- a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy +++ b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy @@ -6,7 +6,6 @@ import org.junit.runners.model.Statement import com.lesfurets.jenkins.unit.BasePipelineTest import com.sap.piper.DefaultValueCache -import com.sap.piper.CommonPipelineEnvironment class JenkinsResetDefaultCacheRule implements TestRule { @@ -28,7 +27,6 @@ class JenkinsResetDefaultCacheRule implements TestRule { @Override void evaluate() throws Throwable { DefaultValueCache.reset() - CommonPipelineEnvironment.getInstance().reset() base.evaluate() } } diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index 49b12f0b5..a0ec24150 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -1,28 +1,146 @@ import com.sap.piper.ConfigurationLoader import com.sap.piper.ConfigurationMerger -import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.analytics.InfluxData class commonPipelineEnvironment implements Serializable { - // We forward everything to the singleton instance of - // commonPipelineEnvironment (CPE) on default value cache. - // - // Some background: each step has its own instance of CPE step. - // In case each instance has its own set of properties these instances - // are configured individually. Properties set on one instance cannot be - // retrieved with another instance. Now each instance forwards to one singleton. - // This means: all instances of the CPE shares the same properties/configuration. + //stores version of the artifact which is build during pipeline run + def artifactVersion - def methodMissing(String name, def args) { - CommonPipelineEnvironment.getInstance().invokeMethod(name, args) + //Stores the current buildResult + String buildResult = 'SUCCESS' + + //stores the gitCommitId as well as additional git information for the build during pipeline run + String gitCommitId + String gitCommitMessage + String gitSshUrl + String gitHttpsUrl + String gitBranch + + String xsDeploymentId + + //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 = [:] + + Map configuration = [:] + Map defaultConfiguration = [:] + + String mtarFilePath + private Map valueMap = [:] + + void setValue(String property, value) { + valueMap[property] = value } - def propertyMissing(def name) { - CommonPipelineEnvironment.getInstance()[name] + def getValue(String property) { + return valueMap.get(property) } - def propertyMissing(def name, def value) { - CommonPipelineEnvironment.getInstance()[name] = value + String changeDocumentId + + def reset() { + appContainerProperties = [:] + artifactVersion = null + + configuration = [:] + + gitCommitId = null + gitCommitMessage = null + gitSshUrl = null + gitHttpsUrl = null + gitBranch = null + + githubOrg = null + githubRepo = null + + mtarFilePath = null + valueMap = [:] + + changeDocumentId = null + + InfluxData.reset() + } + + def setAppContainerProperty(property, value) { + appContainerProperties[property] = value + } + + def getAppContainerProperty(property) { + return appContainerProperties[property] + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataEntry(key, value) { + InfluxData.addField('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomData() { + return InfluxData.getInstance().getFields().jenkins_custom_data + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataTagsEntry(key, value) { + InfluxData.addTag('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomDataTags() { + return InfluxData.getInstance().getTags().jenkins_custom_data + } + + void setInfluxCustomDataMapEntry(measurement, field, value) { + InfluxData.addField(measurement, field, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMap() { + return InfluxData.getInstance().getFields() + } + + def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { + InfluxData.addTag(measurement, tag, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMapTags() { + return InfluxData.getInstance().getTags() + } + + @Deprecated // not used in library + def setInfluxStepData(key, value) { + InfluxData.addField('step_data', key, value) + } + @Deprecated // not used in library + def getInfluxStepData(key) { + return InfluxData.getInstance().getFields()['step_data'][key] + } + + @Deprecated // not used in library + def setInfluxPipelineData(key, value) { + InfluxData.addField('pipeline_data', key, value) + } + @Deprecated // not used in library + def setPipelineMeasurement(key, value){ + setInfluxPipelineData(key, value) + } + @Deprecated // not used in library + def getPipelineMeasurement(key) { + return InfluxData.getInstance().getFields()['pipeline_data'][key] + } + + Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { + Map defaults = [:] + if (includeDefaults) { + defaults = ConfigurationLoader.defaultGeneralConfiguration() + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration(null, stepName), null, defaults) + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration(null, stageName), null, defaults) + } + Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) + config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) + config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) + return config } } From 149cd96dbfab3374ae20182d570fd1f0233014f2 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 27 Sep 2019 09:49:05 +0200 Subject: [PATCH 059/141] Back commonPipelineEnvironment step by shared class --- .../piper/CommonPipelineEnvironment.groovy | 154 ++++++++++++++ .../util/JenkinsResetDefaultCacheRule.groovy | 2 + vars/commonPipelineEnvironment.groovy | 199 ++++++++++++------ 3 files changed, 290 insertions(+), 65 deletions(-) create mode 100644 src/com/sap/piper/CommonPipelineEnvironment.groovy diff --git a/src/com/sap/piper/CommonPipelineEnvironment.groovy b/src/com/sap/piper/CommonPipelineEnvironment.groovy new file mode 100644 index 000000000..655c245c8 --- /dev/null +++ b/src/com/sap/piper/CommonPipelineEnvironment.groovy @@ -0,0 +1,154 @@ +package com.sap.piper; + +import com.sap.piper.analytics.InfluxData + +public class CommonPipelineEnvironment { + + private static CommonPipelineEnvironment INSTANCE = new CommonPipelineEnvironment() + + static CommonPipelineEnvironment getInstance() { + INSTANCE + } + + Map defaultConfiguration = [:] + + // The project config + Map configuration = [:] + + private Map valueMap = [:] + + //stores properties for a pipeline which build an artifact and then bundles it into a container + private Map appContainerProperties = [:] + + //stores version of the artifact which is build during pipeline run + def artifactVersion + + //Stores the current buildResult + String buildResult = 'SUCCESS' + + //stores the gitCommitId as well as additional git information for the build during pipeline run + String gitCommitId + String gitCommitMessage + String gitSshUrl + String gitHttpsUrl + String gitBranch + + //GiutHub specific information + String githubOrg + String githubRepo + + String mtarFilePath + + String changeDocumentId + + String xsDeploymentId + + void setValue(String property, value) { + valueMap[property] = value + } + + def getValue(String property) { + return valueMap.get(property) + } + + def setAppContainerProperty(property, value) { + appContainerProperties[property] = value + } + + def getAppContainerProperty(property) { + return appContainerProperties[property] + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataEntry(key, value) { + InfluxData.addField('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomData() { + return InfluxData.getInstance().getFields().jenkins_custom_data + } + + // goes into measurement jenkins_custom_data + def setInfluxCustomDataTagsEntry(key, value) { + InfluxData.addTag('jenkins_custom_data', key, value) + } + // goes into measurement jenkins_custom_data + @Deprecated // not used in library + def getInfluxCustomDataTags() { + return InfluxData.getInstance().getTags().jenkins_custom_data + } + + void setInfluxCustomDataMapEntry(measurement, field, value) { + InfluxData.addField(measurement, field, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMap() { + return InfluxData.getInstance().getFields() + } + + def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { + InfluxData.addTag(measurement, tag, value) + } + @Deprecated // not used in library + def getInfluxCustomDataMapTags() { + return InfluxData.getInstance().getTags() + } + + @Deprecated // not used in library + def setInfluxStepData(key, value) { + InfluxData.addField('step_data', key, value) + } + @Deprecated // not used in library + def getInfluxStepData(key) { + return InfluxData.getInstance().getFields()['step_data'][key] + } + + @Deprecated // not used in library + def setInfluxPipelineData(key, value) { + InfluxData.addField('pipeline_data', key, value) + } + @Deprecated // not used in library + def setPipelineMeasurement(key, value){ + setInfluxPipelineData(key, value) + } + @Deprecated // not used in library + def getPipelineMeasurement(key) { + return InfluxData.getInstance().getFields()['pipeline_data'][key] + } + + def reset() { + appContainerProperties = [:] + configuration = [:] + artifactVersion = null + + gitCommitId = null + gitCommitMessage = null + gitSshUrl = null + gitHttpsUrl = null + gitBranch = null + + githubOrg = null + githubRepo = null + + mtarFilePath = null + valueMap = [:] + + changeDocumentId = null + + InfluxData.reset() + } + + Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { + Map defaults = [:] + if (includeDefaults) { + defaults = DefaultValueCache.getInstance()?.getDefaultValues()?.general ?: [:] + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration([commonPipelineEnvironment: this], stepName), null, defaults) + defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration([commonPipelineEnvironment: this], stageName), null, defaults) + } + Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) + config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) + config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) + return config + } +} diff --git a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy index 680e2fc93..301c29b13 100644 --- a/test/groovy/util/JenkinsResetDefaultCacheRule.groovy +++ b/test/groovy/util/JenkinsResetDefaultCacheRule.groovy @@ -6,6 +6,7 @@ import org.junit.runners.model.Statement import com.lesfurets.jenkins.unit.BasePipelineTest import com.sap.piper.DefaultValueCache +import com.sap.piper.CommonPipelineEnvironment class JenkinsResetDefaultCacheRule implements TestRule { @@ -27,6 +28,7 @@ class JenkinsResetDefaultCacheRule implements TestRule { @Override void evaluate() throws Throwable { DefaultValueCache.reset() + CommonPipelineEnvironment.getInstance().reset() base.evaluate() } } diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index a0ec24150..4d1b8e468 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -1,36 +1,121 @@ +import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.ConfigurationLoader import com.sap.piper.ConfigurationMerger import com.sap.piper.analytics.InfluxData class commonPipelineEnvironment implements Serializable { - //stores version of the artifact which is build during pipeline run - def artifactVersion + // + // Instances of this step does not keep any state. Everything is redirected to + // the singleton CommonPipelineEnvironment. Since all instances of this step + // share the same state through the singleton CommonPipelineEnvironment all + // instances can be used in the same way. + // + // [Q] Why not simplify this using reflection, e.g. methodMissing/invokeMethod and + // similar? Wouln't this be simpler e.g. wrt. adding new properties? With the + // approach here we have to add the new property here and in the singleton class. + // And in general this look like boiler plate code ... + // [A] Does not work with Jenkins since a security manager prohibits this. + // - //Stores the current buildResult - String buildResult = 'SUCCESS' + void setArtifactVersion(String artifactVersion) { + CommonPipelineEnvironment.getInstance().artifactVersion = artifactVersion + } + String getArtifactVersion() { + CommonPipelineEnvironment.getInstance().artifactVersion + } - //stores the gitCommitId as well as additional git information for the build during pipeline run - String gitCommitId - String gitCommitMessage - String gitSshUrl - String gitHttpsUrl - String gitBranch + void setBuildResult(String buildResult) { + CommonPipelineEnvironment.getInstance().buildResult = buildResult + } + String getBuildResult() { + CommonPipelineEnvironment.getInstance().buildResult + } - String xsDeploymentId + void setGitCommitId(String gitCommitId) { + CommonPipelineEnvironment.getInstance().gitCommitId = gitCommitId + } + String getGitCommitId() { + CommonPipelineEnvironment.getInstance().gitCommitId + } - //GiutHub specific information - String githubOrg - String githubRepo + void setGitCommitMessage(String gitCommitMessage) { + CommonPipelineEnvironment.getInstance().gitCommitMessage = gitCommitMessage + } + String getGitCommitMessage() { + CommonPipelineEnvironment.getInstance().gitCommitMessage + } - //stores properties for a pipeline which build an artifact and then bundles it into a container - private Map appContainerProperties = [:] + void setGitSshUrl(String gitSshUrl) { + CommonPipelineEnvironment.getInstance().gitSshUrl = gitSshUrl + } + String getGitSshUrl() { + CommonPipelineEnvironment.getInstance().gitSshUrl + } - Map configuration = [:] - Map defaultConfiguration = [:] + void setGitHttpsUrl(String gitHttpsUrl) { + CommonPipelineEnvironment.getInstance().gitHttpsUrl = gitHttpsUrl + } + String getGitHttpsUrl() { + CommonPipelineEnvironment.getInstance().gitHttpsUrl + } - String mtarFilePath - private Map valueMap = [:] + void setGitBranch(String gitBranch) { + CommonPipelineEnvironment.getInstance().gitBranch = gitBranch + } + String getGitBranch() { + CommonPipelineEnvironment.getInstance().gitBranch + } + + void setXsDeploymentId(String xsDeploymentId) { + CommonPipelineEnvironment.getInstance().xsDeploymentId = xsDeploymentId + } + String getXsDeploymentId() { + CommonPipelineEnvironment.getInstance().xsDeploymentId + } + + void setGithubOrg(String githubOrg) { + CommonPipelineEnvironment.getInstance().githubOrg = githubOrg + } + String getGithubOrg() { + CommonPipelineEnvironment.getInstance().githubOrg + } + + + void setGithubRepo(String githubRepo) { + CommonPipelineEnvironment.getInstance().githubRepo = githubRepo + } + String getGithubRepo() { + CommonPipelineEnvironment.getInstance().githubRepo + } + + Map getConfiguration() { + CommonPipelineEnvironment.getInstance().configuration + } + void setConfiguration(Map configuration) { + CommonPipelineEnvironment.getInstance().configuration = configuration + } + + Map getDefaultConfiguration() { + CommonPipelineEnvironment.getInstance().defaultConfiguration + } + void setDefaultConfiguration(Map defaultConfiguration) { + CommonPipelineEnvironment.getInstance().defaultConfiguration = defaultConfiguration + } + + String getMtarFilePath() { + CommonPipelineEnvironment.getInstance().mtarFilePath + } + void setMtarFilePath(String mtarFilePath) { + CommonPipelineEnvironment.getInstance().mtarFilePath = mtarFilePath + } + + Map getValueMap() { + CommonPipelineEnvironment.getInstance().valueMap + } + void setValueMap(Map valueMap) { + CommonPipelineEnvironment.getInstance().valueMap = valueMap + } void setValue(String property, value) { valueMap[property] = value @@ -40,107 +125,91 @@ class commonPipelineEnvironment implements Serializable { return valueMap.get(property) } - String changeDocumentId + String getChangeDocumentId() { + CommonPipelineEnvironment.getInstance().changeDocumentId + } + void setChangeDocumentId(String changeDocumentId) { + CommonPipelineEnvironment.getInstance().changeDocumentId = changeDocumentId + } def reset() { - appContainerProperties = [:] - artifactVersion = null + CommonPipelineEnvironment.getInstance().reset() + } - configuration = [:] - - gitCommitId = null - gitCommitMessage = null - gitSshUrl = null - gitHttpsUrl = null - gitBranch = null - - githubOrg = null - githubRepo = null - - mtarFilePath = null - valueMap = [:] - - changeDocumentId = null - - InfluxData.reset() + Map getAppContainerProperties() { + CommonPipelineEnvironment.getInstance().appContainerProperties + } + void setAppContainerProperties(Map appContainerProperties) { + CommonPipelineEnvironment.getInstance().appContainerProperties = appContainerProperties } def setAppContainerProperty(property, value) { - appContainerProperties[property] = value + getAppContainerProperties()[property] = value } def getAppContainerProperty(property) { - return appContainerProperties[property] + return getAppContainerProperties()[property] } // goes into measurement jenkins_custom_data def setInfluxCustomDataEntry(key, value) { - InfluxData.addField('jenkins_custom_data', key, value) + CommonPipelineEnvironment.getInstance().setInfluxCustomDataEntry(key, value) } // goes into measurement jenkins_custom_data @Deprecated // not used in library def getInfluxCustomData() { - return InfluxData.getInstance().getFields().jenkins_custom_data + CommonPipelineEnvironment.getInstance().getInfluxCustomData() } // goes into measurement jenkins_custom_data def setInfluxCustomDataTagsEntry(key, value) { - InfluxData.addTag('jenkins_custom_data', key, value) + CommonPipelineEnvironment.getInstance().setInfluxCustomDataTagsEntry(key, value) } // goes into measurement jenkins_custom_data @Deprecated // not used in library def getInfluxCustomDataTags() { - return InfluxData.getInstance().getTags().jenkins_custom_data + CommonPipelineEnvironment.getInstance().getInfluxCustomDataTags() } void setInfluxCustomDataMapEntry(measurement, field, value) { - InfluxData.addField(measurement, field, value) + CommonPipelineEnvironment.getInstance().setInfluxCustomDataMapEntry(measurement, field, value) } @Deprecated // not used in library def getInfluxCustomDataMap() { - return InfluxData.getInstance().getFields() + CommonPipelineEnvironment.getInstance().getInfluxCustomDataMap() } def setInfluxCustomDataMapTagsEntry(measurement, tag, value) { - InfluxData.addTag(measurement, tag, value) + CommonPipelineEnvironment.getInstance().setInfluxCustomDataMapTagsEntry(measurement, tag, value) } @Deprecated // not used in library def getInfluxCustomDataMapTags() { - return InfluxData.getInstance().getTags() + CommonPipelineEnvironment.getInstance().getInfluxCustomDataMapTags() } @Deprecated // not used in library def setInfluxStepData(key, value) { - InfluxData.addField('step_data', key, value) + CommonPipelineEnvironment.getInstance().setInfluxStepData(key, value) } @Deprecated // not used in library def getInfluxStepData(key) { - return InfluxData.getInstance().getFields()['step_data'][key] + CommonPipelineEnvironment.getInstance().getInfluxStepData(key) } @Deprecated // not used in library def setInfluxPipelineData(key, value) { - InfluxData.addField('pipeline_data', key, value) + CommonPipelineEnvironment.getInstance().setInfluxPipelineData(key, value) } @Deprecated // not used in library def setPipelineMeasurement(key, value){ - setInfluxPipelineData(key, value) + CommonPipelineEnvironment.getInstance().setPipelineMeasurement(key, value) } @Deprecated // not used in library def getPipelineMeasurement(key) { - return InfluxData.getInstance().getFields()['pipeline_data'][key] + CommonPipelineEnvironment.getInstance().getPipelineMeasurement(key) } Map getStepConfiguration(stepName, stageName = env.STAGE_NAME, includeDefaults = true) { - Map defaults = [:] - if (includeDefaults) { - defaults = ConfigurationLoader.defaultGeneralConfiguration() - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStepConfiguration(null, stepName), null, defaults) - defaults = ConfigurationMerger.merge(ConfigurationLoader.defaultStageConfiguration(null, stageName), null, defaults) - } - Map config = ConfigurationMerger.merge(configuration.get('general') ?: [:], null, defaults) - config = ConfigurationMerger.merge(configuration.get('steps')?.get(stepName) ?: [:], null, config) - config = ConfigurationMerger.merge(configuration.get('stages')?.get(stageName) ?: [:], null, config) - return config + CommonPipelineEnvironment.getInstance().getStepConfiguration(stepName, stageName, includeDefaults) } } From facebdbdbb27ee9e2ef2ed4834b402678d7b4a58 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 27 Sep 2019 16:10:43 +0200 Subject: [PATCH 060/141] Provide the logs for cf deploy (#865) * Provide the logs for cf deployx * Surround cf trace output by comments so that it can be easily retrieved * Tests --- test/groovy/CloudFoundryDeployTest.groovy | 58 ++++++++++++++++++++++- vars/cloudFoundryDeploy.groovy | 32 ++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index 26adaf1b0..f558dd943 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -25,6 +25,7 @@ import static org.hamcrest.Matchers.hasItem import static org.hamcrest.Matchers.is import static org.hamcrest.Matchers.not import static org.hamcrest.Matchers.hasEntry +import static org.hamcrest.Matchers.allOf import static org.hamcrest.Matchers.containsString class CloudFoundryDeployTest extends BasePiperTest { @@ -39,7 +40,7 @@ class CloudFoundryDeployTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this) - private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this) + private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this, []) private writeInfluxMap = [:] @@ -772,4 +773,59 @@ class CloudFoundryDeployTest extends BasePiperTest { assertThat(shellRule.shell, hasItem(containsString("cf blue-green-deploy testAppName --delete-old-apps -f 'test.yml'"))) assertThat(shellRule.shell, hasItem(containsString("cf logout"))) } + + @Test + void testTraceOutputOnVerbose() { + + fileExistsRule.existingFiles.addAll( + 'test.yml', + 'cf.log' + ) + + new File(tmpDir, 'cf.log') << 'Hello SAP' + + readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + cloudFoundry: [ + org: 'testOrg', + space: 'testSpace', + manifest: 'test.yml', + ], + cfCredentialsId: 'test_cfCredentialsId', + verbose: true + ]) + + assertThat(loggingRule.log, allOf( + containsString('### START OF CF CLI TRACE OUTPUT ###'), + containsString('Hello SAP'), + containsString('### END OF CF CLI TRACE OUTPUT ###'))) + } + + @Test + void testTraceNoTraceFileWritten() { + + fileExistsRule.existingFiles.addAll( + 'test.yml', + ) + + readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + cloudFoundry: [ + org: 'testOrg', + space: 'testSpace', + manifest: 'test.yml', + ], + cfCredentialsId: 'test_cfCredentialsId', + verbose: true + ]) + + assertThat(loggingRule.log, containsString('No trace file found')) + } + } diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index 2690e70f9..3921ca20f 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -116,7 +116,8 @@ import groovy.transform.Field /** * Expected status code returned by the check. */ - 'smokeTestStatusCode' + 'smokeTestStatusCode', + 'verbose', ] @Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', manifestVariablesFiles: 'cfManifestVariablesFiles', manifestVariables: 'cfManifestVariables', org: 'cfOrg', space: 'cfSpace']] @@ -256,14 +257,27 @@ def deployMta (config) { usernameVariable: 'username' )]) { echo "[${STEP_NAME}] Deploying MTA (${config.mtaPath}) with following parameters: ${config.mtaExtensionDescriptor} ${config.mtaDeployParameters}" + def cfTraceFile = 'cf.log' def returnCode = sh returnStatus: true, script: """#!/bin/bash export HOME=${config.dockerWorkspace} + export CF_TRACE="${cfTraceFile}" set +x set -e cf api ${config.cloudFoundry.apiEndpoint} cf login -u ${username} -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" cf plugins cf ${deployCommand} ${config.mtaPath} ${config.mtaDeployParameters} ${config.mtaExtensionDescriptor}""" + if(config.verbose || returnCode != 0) { + if(fileExists(file: cfTraceFile)) { + echo '### START OF CF CLI TRACE OUTPUT ###' + // Would be nice to inline the two next lines, but that is not understood by the test framework + def cfTrace = readFile(file: cfTraceFile) + echo cfTrace + echo '### END OF CF CLI TRACE OUTPUT ###' + } else { + echo "No trace file found at '${cfTraceFile}'" + } + } if(returnCode != 0){ error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." } @@ -400,15 +414,31 @@ def deployCfNative (config) { passwordVariable: 'password', usernameVariable: 'username' )]) { + + def cfTraceFile = 'cf.log' + def returnCode = sh returnStatus: true, script: """#!/bin/bash set +x set -e export HOME=${config.dockerWorkspace} + export CF_TRACE=${cfTraceFile} cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" cf plugins cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} """ + if(config.verbose || returnCode != 0) { + if(fileExists(file: cfTraceFile)) { + echo '### START OF CF CLI TRACE OUTPUT ###' + // Would be nice to inline the two next lines, but that is not understood by the test framework + def cfTrace = readFile(file: cfTraceFile) + echo cfTrace + echo '### END OF CF CLI TRACE OUTPUT ###' + } else { + echo "No trace file found at '${cfTraceFile}'" + } + } + if(returnCode != 0){ error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." } From afb33f78c77d8c6139a65a4ece2c9e8b282e902a Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 30 Sep 2019 12:54:16 +0200 Subject: [PATCH 061/141] cm client only required in case we are not running via docker (#890) --- documentation/docs/steps/checkChangeInDevelopment.md | 2 +- documentation/docs/steps/transportRequestCreate.md | 2 +- documentation/docs/steps/transportRequestRelease.md | 2 +- documentation/docs/steps/transportRequestUploadFile.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/documentation/docs/steps/checkChangeInDevelopment.md b/documentation/docs/steps/checkChangeInDevelopment.md index b79406a5b..1c6bc3873 100644 --- a/documentation/docs/steps/checkChangeInDevelopment.md +++ b/documentation/docs/steps/checkChangeInDevelopment.md @@ -4,7 +4,7 @@ ## Prerequisites -* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. +* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. **Note:** This is only required if you don't use a Docker-based environment. ## ${docGenParameters} diff --git a/documentation/docs/steps/transportRequestCreate.md b/documentation/docs/steps/transportRequestCreate.md index bcd69d0b1..2d7a24d3b 100644 --- a/documentation/docs/steps/transportRequestCreate.md +++ b/documentation/docs/steps/transportRequestCreate.md @@ -4,7 +4,7 @@ ## Prerequisites -* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. +* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. **Note:** This is only required if you don't use a Docker-based environment. * Solution Manager version `ST720 SP08` or newer. ## ${docGenParameters} diff --git a/documentation/docs/steps/transportRequestRelease.md b/documentation/docs/steps/transportRequestRelease.md index d38b95b0f..90b1d97c9 100644 --- a/documentation/docs/steps/transportRequestRelease.md +++ b/documentation/docs/steps/transportRequestRelease.md @@ -4,7 +4,7 @@ ## Prerequisites -* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. +* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. **Note:** This is only required if you don't use a Docker-based environment. ## ${docGenParameters} diff --git a/documentation/docs/steps/transportRequestUploadFile.md b/documentation/docs/steps/transportRequestUploadFile.md index 63b9ac7cb..7aab15e39 100644 --- a/documentation/docs/steps/transportRequestUploadFile.md +++ b/documentation/docs/steps/transportRequestUploadFile.md @@ -4,7 +4,7 @@ ## Prerequisites -* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. +* **[Change Management Client 2.0.0 or compatible version](http://central.maven.org/maven2/com/sap/devops/cmclient/dist.cli/)** - available for download on Maven Central. **Note:** This is only required if you don't use a Docker-based environment. ## ${docGenParameters} From 1a34d679d4c6b3429947adaec180ee39e563679b Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 1 Oct 2019 11:24:07 +0200 Subject: [PATCH 062/141] =?UTF-8?q?cm=20scenario:=20mta=20and=20node=20onl?= =?UTF-8?q?y=20required=20in=20case=20we=20are=20not=20running=20in?= =?UTF-8?q?=E2=80=A6=20(#889)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cm scenario: mta and node only required in case we are not running in a docker enviroment * Update documentation/docs/scenarios/changeManagement.md Co-Authored-By: SarahNoack <44202907+SarahNoack@users.noreply.github.com> * Update documentation/docs/scenarios/changeManagement.md Co-Authored-By: SarahNoack <44202907+SarahNoack@users.noreply.github.com> --- documentation/docs/scenarios/changeManagement.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/scenarios/changeManagement.md b/documentation/docs/scenarios/changeManagement.md index 1d76433e4..8f8cb2c04 100644 --- a/documentation/docs/scenarios/changeManagement.md +++ b/documentation/docs/scenarios/changeManagement.md @@ -8,8 +8,8 @@ Set up an agile development process with Jenkins CI, which automatically feeds c * You have installed Jenkins 2.60.3 or higher. * You have set up Project “Piper”. See [README](https://github.com/SAP/jenkins-library/blob/master/README.md). * You have installed SAP Solution Manager 7.2 SP6. See [README](https://github.com/SAP/devops-cm-client/blob/master/README.md). -* You have installed the Multi-Target Application (MTA) Archive Builder 1.0.6 or newer. See [SAP Development Tools](https://tools.hana.ondemand.com/#cloud). -* You have installed Node.js including node and npm. See [Node.js](https://nodejs.org/en/download/). +* You have installed the Multi-Target Application (MTA) Archive Builder 1.0.6 or newer. See [SAP Development Tools](https://tools.hana.ondemand.com/#cloud). **Note:** This is only required if you don't use a Docker-based environment. +* You have installed Node.js including node and npm. See [Node.js](https://nodejs.org/en/download/). **Note:** This is only required if you don't use a Docker-based environment. ## Context From 8b26406fc7275a0ea808e56f28d6c0cea05b9b91 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 2 Oct 2019 13:28:54 +0200 Subject: [PATCH 063/141] Provide additional opts for cf deploy (#872) * Provide additional opts for cf deploy Inside cloudFoundyDeploy we use these cf commands o login o plugins o blue-green-deploy o push o deploy o bg-deploy o stop o logout o logout and stop does not provide any options o plugins provides options (--checksum --outdated) but it is unlikely that these options can be used in a reasonable way during the deploy process. o login now uses `loginOpts` o The other commands uses now `deployOpts` * provide additional opts also for cf api calls * Provide more log when verbose * re-use mtaDeployParameters and adjust names of other params (api, login) accordingly * Streamline naming * distinuish between cfNative and mta deploy params * Add cfNativeDeployParam default * login and api paramters are not under cloudFoundry --- resources/default_pipeline_environment.yml | 3 ++ test/groovy/CloudFoundryDeployTest.groovy | 58 ++++++++++++++++++++++ vars/cloudFoundryDeploy.groovy | 45 ++++++++++++++--- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 7a221b7f2..94f948414 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -152,9 +152,12 @@ steps: cloudFoundryDeploy: cloudFoundry: apiEndpoint: 'https://api.cf.eu10.hana.ondemand.com' + apiParameters: '' + loginParameters: '' deployTool: 'cf_native' deployType: 'standard' keepOldInstance: false + cfNativeDeployParameters: '' mtaDeployParameters: '-f' mtaExtensionDescriptor: '' mtaPath: '' diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index f558dd943..e45cb522c 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -828,4 +828,62 @@ class CloudFoundryDeployTest extends BasePiperTest { assertThat(loggingRule.log, containsString('No trace file found')) } + @Test + void testAdditionCfNativeOpts() { + + readYamlRule.registerYaml('test.yml', "applications: [[name: 'manifestAppName']]") + helper.registerAllowedMethod('writeYaml', [Map], { Map parameters -> + generatedFile = parameters.file + data = parameters.data + }) + nullScript.commonPipelineEnvironment.setArtifactVersion('1.2.3') + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + loginParameters: '--some-login-opt value', + cfNativeDeployParameters: '--some-deploy-opt cf-value', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml' + ]) + + assertThat(shellRule.shell, hasItem( + stringContainsInOrder([ + 'cf login ', '--some-login-opt value', + 'cf push', '--some-deploy-opt cf-value']))) + + } + + @Test + void testAdditionMtaOpts() { + + stepRule.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + cloudFoundry: [ + org: 'testOrg', + space: 'testSpace', + ], + apiParameters: '--some-api-opt value', + loginParameters: '--some-login-opt value', + mtaDeployParameters: '--some-deploy-opt mta-value', + cfCredentialsId: 'test_cfCredentialsId', + deployTool: 'mtaDeployPlugin', + deployType: 'blue-green', + mtaPath: 'target/test.mtar' + ]) + + assertThat(shellRule.shell, hasItem( + stringContainsInOrder([ + 'cf api', '--some-api-opt value', + 'cf login ', '--some-login-opt value', + 'cf bg-deploy', '--some-deploy-opt mta-value']))) + + } + } diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index 3921ca20f..6d31d3aca 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -97,7 +97,21 @@ import groovy.transform.Field /** @see dockerExecute */ 'stashContent', /** - * Defines additional parameters passed to mta for deployment with the mtaDeployPlugin. + * Additional parameters passed to cf native deployment command. + */ + 'cfNativeDeployParameters', + /** + * Addition command line options for cf api command. + * No escaping/quoting is performed. Not recommanded for productive environments. + */ + 'apiParameters', + /** + * Addition command line options for cf login command. + * No escaping/quoting is performed. Not recommanded for productive environments. + */ + 'loginParameters', + /** + * Additional parameters passed to mta deployment command. */ 'mtaDeployParameters', /** @@ -117,6 +131,10 @@ import groovy.transform.Field * Expected status code returned by the check. */ 'smokeTestStatusCode', + /** + * Provides more output. May reveal sensitive information. + * @possibleValues true, false + */ 'verbose', ] @@ -258,15 +276,23 @@ def deployMta (config) { )]) { echo "[${STEP_NAME}] Deploying MTA (${config.mtaPath}) with following parameters: ${config.mtaExtensionDescriptor} ${config.mtaDeployParameters}" def cfTraceFile = 'cf.log' - def returnCode = sh returnStatus: true, script: """#!/bin/bash + def deployScript = """#!/bin/bash export HOME=${config.dockerWorkspace} export CF_TRACE="${cfTraceFile}" set +x set -e - cf api ${config.cloudFoundry.apiEndpoint} - cf login -u ${username} -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" + cf api ${config.cloudFoundry.apiEndpoint} ${config.apiParameters} + cf login -u ${username} -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" ${config.loginParameters} cf plugins cf ${deployCommand} ${config.mtaPath} ${config.mtaDeployParameters} ${config.mtaExtensionDescriptor}""" + + if(config.verbose) { + // Password contained in output below is hidden by withCredentials + echo "[INFO][$STEP_NAME] Executing deploy command '${deployScript}'" + } + + def returnCode = sh returnStatus: true, script: deployScript + if(config.verbose || returnCode != 0) { if(fileExists(file: cfTraceFile)) { echo '### START OF CF CLI TRACE OUTPUT ###' @@ -417,16 +443,21 @@ def deployCfNative (config) { def cfTraceFile = 'cf.log' - def returnCode = sh returnStatus: true, script: """#!/bin/bash + def deployScript = """#!/bin/bash set +x set -e export HOME=${config.dockerWorkspace} export CF_TRACE=${cfTraceFile} - cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" + cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" ${config.loginParameters} cf plugins - cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} + cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} ${config.cfNativeDeployParameters} """ + if(config.verbose) { + // Password contained in output below is hidden by withCredentials + echo "[INFO][${STEP_NAME}] Executing command: '${deployScript}'." + } + def returnCode = sh returnStatus: true, script: deployScript if(config.verbose || returnCode != 0) { if(fileExists(file: cfTraceFile)) { echo '### START OF CF CLI TRACE OUTPUT ###' From 79348f68de4af72d2de9571735fc9d9e294d5ec1 Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Tue, 15 Oct 2019 11:50:35 +0200 Subject: [PATCH 064/141] Add archiving of new UA log files --- vars/whitesourceExecuteScan.groovy | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index c7c0cfd21..fa6477f47 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -407,6 +407,9 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU // archive whitesource debug files, if available archiveArtifacts artifacts: "**/ws-l*", allowEmptyArchive: true + + // archive UA log file + archiveArtifacts artifacts: "/var/log/UA/*", allowEmptyArchive: true } break } From f2ce3d5b2d08f37651a22e9aa3b05897f873897b Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Tue, 15 Oct 2019 13:19:39 +0200 Subject: [PATCH 065/141] Extend default configuration --- src/com/sap/piper/WhitesourceConfigurationHelper.groovy | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/com/sap/piper/WhitesourceConfigurationHelper.groovy b/src/com/sap/piper/WhitesourceConfigurationHelper.groovy index db2d63a79..b9bbfc9d3 100644 --- a/src/com/sap/piper/WhitesourceConfigurationHelper.groovy +++ b/src/com/sap/piper/WhitesourceConfigurationHelper.groovy @@ -23,7 +23,10 @@ class WhitesourceConfigurationHelper implements Serializable { ] } if(config.verbose) - mapping += [name: 'log.level', value: 'debug'] + mapping += [ + [name: 'log.level', value: 'debug'], + [name: 'log.files.level', value: 'debug'] + ] mapping += [ [name: 'apiKey', value: config.whitesource.orgToken, force: true], From 0dffbaedb100b3af3ccbb4a42d29d9662a0243d0 Mon Sep 17 00:00:00 2001 From: Florian Wilhelm Date: Tue, 15 Oct 2019 14:06:02 +0200 Subject: [PATCH 066/141] openjdk8 --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index fe65a888b..d605ef1b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ branches: - master - /^it\/.*$/ language: groovy +jdk: + - openjdk8 sudo: required services: - docker From 88ab65cf7b0b021512b8e06dba1e53a092bfe9ad Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Wed, 16 Oct 2019 10:54:17 +0200 Subject: [PATCH 067/141] Fix UA log path for archiving --- vars/whitesourceExecuteScan.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index fa6477f47..187deaf96 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -409,7 +409,7 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU archiveArtifacts artifacts: "**/ws-l*", allowEmptyArchive: true // archive UA log file - archiveArtifacts artifacts: "/var/log/UA/*", allowEmptyArchive: true + archiveArtifacts artifacts: "/var/log/UA/**/*.log", allowEmptyArchive: true } break } From cf64a0d0988477b2f1b1fe90ef54924847d01862 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Wed, 16 Oct 2019 13:49:47 +0200 Subject: [PATCH 068/141] whitesourceExecuteScan: Transfer logs into workspace to allow archiving --- vars/whitesourceExecuteScan.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index 187deaf96..a3dbda222 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -409,7 +409,8 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU archiveArtifacts artifacts: "**/ws-l*", allowEmptyArchive: true // archive UA log file - archiveArtifacts artifacts: "/var/log/UA/**/*.log", allowEmptyArchive: true + sh 'cp -Rf --parents /var/log/UA/* .' + archiveArtifacts artifacts: "var/log/UA/**/*.log", allowEmptyArchive: true } break } From 3d1da388c8d693612f887b3490765c31821ce8d9 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Wed, 16 Oct 2019 14:58:54 +0200 Subject: [PATCH 069/141] Update whitesourceExecuteScan.groovy --- vars/whitesourceExecuteScan.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index a3dbda222..dacfde861 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -409,8 +409,8 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU archiveArtifacts artifacts: "**/ws-l*", allowEmptyArchive: true // archive UA log file - sh 'cp -Rf --parents /var/log/UA/* .' - archiveArtifacts artifacts: "var/log/UA/**/*.log", allowEmptyArchive: true + sh "cp -Rf --parents /var/log/UA/* ." + archiveArtifacts artifacts: "**/var/log/UA/**/*.log", allowEmptyArchive: true } break } From 5bf5a6013ca304eb8d9fbf57542fb056e9ea922d Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Thu, 17 Oct 2019 15:24:09 +0200 Subject: [PATCH 070/141] Update whitesourceExecuteScan.groovy --- vars/whitesourceExecuteScan.groovy | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index dacfde861..96027dcc7 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -408,9 +408,13 @@ private def triggerWhitesourceScanWithUserKey(script, config, utils, descriptorU // archive whitesource debug files, if available archiveArtifacts artifacts: "**/ws-l*", allowEmptyArchive: true - // archive UA log file - sh "cp -Rf --parents /var/log/UA/* ." - archiveArtifacts artifacts: "**/var/log/UA/**/*.log", allowEmptyArchive: true + try { + // archive UA log file + sh "cp -Rf --parents /var/log/UA/* ." + archiveArtifacts artifacts: "**/var/log/UA/**/*.log", allowEmptyArchive: true + } catch (e) { + echo "Failed archiving WhiteSource UA logs" + } } break } From 8cfac8d43f815061c6a140ba9427fa4db5201164 Mon Sep 17 00:00:00 2001 From: Shanuson <54803480+Shanuson@users.noreply.github.com> Date: Tue, 22 Oct 2019 11:15:03 +0200 Subject: [PATCH 071/141] new step cloudFoundryServiceCreate (#892) # Changes This PR adds a new step: cloudFoundryServiceCreate There is a cf community plugin [Create-Service-Push](https://github.com/dawu415/CF-CLI-Create-Service-Push-Plugin) available to apply infrastructure as code to Cloud Foundry. The plugin uses a manifest.yml to create services in a targeted CF space. The proposed step provides an interface to this plugin. Already done: - [x] Tests - [x] Documentation Further actions: - a Refactoring: Move varOptions and varsFileOption code into a class and make us of this here and in cloudFoundryDeploy step. -> Is it ok to use the CfManifestUtils, or add it as a new class to variablesubstitiion package? - enhance the s4sdk cf cli docker image to include the plugin. --- resources/default_pipeline_environment.yml | 6 + .../CloudFoundryCreateServiceTest.groovy | 284 ++++++++++++++++++ vars/cloudFoundryCreateService.groovy | 179 +++++++++++ 3 files changed, 469 insertions(+) create mode 100644 test/groovy/CloudFoundryCreateServiceTest.groovy create mode 100644 vars/cloudFoundryCreateService.groovy diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 94f948414..25a1fae41 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -181,6 +181,12 @@ steps: stashContent: - 'tests' testReportFilePath: 'cst-report.json' + cloudFoundryCreateService: + cloudFoundry: + apiEndpoint: 'https://api.cf.eu10.hana.ondemand.com' + serviceManifest: 'service-manifest.yml' + dockerImage: 'ppiper/cf-cli' + dockerWorkspace: '/home/piper' detectExecuteScan: detect: projectVersion: '1' diff --git a/test/groovy/CloudFoundryCreateServiceTest.groovy b/test/groovy/CloudFoundryCreateServiceTest.groovy new file mode 100644 index 000000000..005e807d4 --- /dev/null +++ b/test/groovy/CloudFoundryCreateServiceTest.groovy @@ -0,0 +1,284 @@ +import com.sap.piper.JenkinsUtils +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.JenkinsCredentialsRule +import util.JenkinsEnvironmentRule +import util.JenkinsDockerExecuteRule +import util.JenkinsFileExistsRule +import util.JenkinsLoggingRule +import util.JenkinsReadFileRule +import util.JenkinsShellCallRule +import util.JenkinsStepRule +import util.JenkinsWriteFileRule +import util.JenkinsReadYamlRule +import util.Rules + +import static org.hamcrest.Matchers.stringContainsInOrder +import static org.junit.Assert.* + +import static org.hamcrest.Matchers.hasItem +import static org.hamcrest.Matchers.is +import static org.hamcrest.Matchers.not +import static org.hamcrest.Matchers.hasEntry +import static org.hamcrest.Matchers.containsString + +class CloudFoundryCreateServiceTest extends BasePiperTest { + + private File tmpDir = File.createTempDir() + private ExpectedException thrown = ExpectedException.none() + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsDockerExecuteRule dockerExecuteRule = new JenkinsDockerExecuteRule(this) + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsEnvironmentRule environmentRule = new JenkinsEnvironmentRule(this) + private JenkinsReadYamlRule readYamlRule = new JenkinsReadYamlRule(this) + private JenkinsFileExistsRule fileExistsRule = new JenkinsFileExistsRule(this) + private JenkinsCredentialsRule credentialsRule = new JenkinsCredentialsRule(this).withCredentials('test_cfCredentialsId', 'test_cf', '********') + + private writeInfluxMap = [:] + + class JenkinsUtilsMock extends JenkinsUtils { + def isJobStartedByUser() { + return true + } + } + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(readYamlRule) + .around(thrown) + .around(loggingRule) + .around(shellRule) + .around(dockerExecuteRule) + .around(environmentRule) + .around(fileExistsRule) + .around(credentialsRule) + .around(stepRule) // needs to be activated after dockerExecuteRule, otherwise executeDocker is not mocked + + @Before + void init() { + helper.registerAllowedMethod('influxWriteData', [Map.class], {m -> + writeInfluxMap = m + }) + fileExistsRule.registerExistingFile('test.yml') + } + + @Test + void testVarsListNotAList() { + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryCreateService] ERROR: Parameter config.cloudFoundry.manifestVariables is not a List!') + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariables: 'notAList' + ]) + } + + @Test + void testVarsListEntryIsNotAMap() { + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryCreateService] ERROR: Parameter config.cloudFoundry.manifestVariables.notAMap is not a Map!') + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariables: ['notAMap'] + ]) + } + + @Test + void testVarsFilesListIsNotAList() { + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryCreateService] ERROR: Parameter config.cloudFoundry.manifestVariablesFiles is not a List!') + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariablesFiles: 'notAList' + ]) + } + + @Test + void testRunCreateServicePushPlugin() { + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml' + ]) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) + assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + + @Test + void testWithVariableSubstitutionFromVarsListAndVarsFile() { + String varsFileName='vars.yml' + fileExistsRule.registerExistingFile(varsFileName) + List varsList = [["appName" : "testApplicationFromVarsList"]] + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariablesFiles: [varsFileName], + cfManifestVariables: varsList + ]) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) + assertThat(shellRule.shell, hasItem(containsString("cf create-service-push --no-push -f 'test.yml' --var appName='testApplicationFromVarsList' --vars-file 'vars.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } + + @Test + void testEscapesUsernameAndPasswordInShellCall() { + credentialsRule.credentials.put('escape_cfCredentialsId',[user:"aUserWithA'",passwd:"passHasA'"]) + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'escape_cfCredentialsId', + cfServiceManifest: 'test.yml' + ]) + + assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'aUserWithA'"'"'' -p 'passHasA'"'"'' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"""))) + } + + @Test + void testEscapesSpaceNameInShellCall() { + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: "testSpaceWith'", + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml' + ]) + assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpaceWith'"'"''"""))) + } + + @Test + void testEscapesOrgNameInShellCall() { + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: "testOrgWith'", + cfSpace: "testSpace", + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml' + ]) + assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrgWith'"'"'' -s 'testSpace'"""))) + } + + @Test + void testWithVariableSubstitutionFromVarsListGetsEscaped() { + List varsList = [["appName" : "testApplicationFromVarsListWith'"]] + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariables: varsList + ]) + + assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push -f 'test.yml' --var appName='testApplicationFromVarsListWith'"'"''"""))) + } + + @Test + void testWithVariableSubstitutionFromVarsFilesGetsEscaped() { + String varsFileName="varsWith'.yml" + fileExistsRule.registerExistingFile(varsFileName) + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml', + cfManifestVariablesFiles: [varsFileName] + ]) + + assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push -f 'test.yml' --vars-file 'varsWith'"'"'.yml'"""))) + } + + @Test + void testCfLogoutHappensEvenWhenCreateServiceFails() { + + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryCreateService] ERROR: The execution of the create-service-push plugin failed, see the logs above for more details.') + + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX,/(create-service-push)/,128) + + stepRule.step.cloudFoundryCreateService([ + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: new JenkinsUtilsMock(), + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfServiceManifest: 'test.yml' + ]) + + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) + assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf logout"))) + } +} diff --git a/vars/cloudFoundryCreateService.groovy b/vars/cloudFoundryCreateService.groovy new file mode 100644 index 000000000..cff2e4299 --- /dev/null +++ b/vars/cloudFoundryCreateService.groovy @@ -0,0 +1,179 @@ +import com.sap.piper.GenerateDocumentation +import com.sap.piper.BashUtils +import com.sap.piper.JenkinsUtils +import com.sap.piper.Utils +import com.sap.piper.ConfigurationHelper + +import groovy.transform.Field + +import static com.sap.piper.Prerequisites.checkScript + +@Field String STEP_NAME = 'cloudFoundryCreateService' + +@Field Set STEP_CONFIG_KEYS = [ + 'cloudFoundry', + /** + * Cloud Foundry API endpoint. + * @parentConfigKey cloudFoundry + */ + 'apiEndpoint', + /** + * Credentials to be used for deployment. + * @parentConfigKey cloudFoundry + */ + 'credentialsId', + /** + * Defines the manifest Yaml file that contains the information about the to be created services that will be passed to a Create-Service-Push cf cli plugin. + * @parentConfigKey cloudFoundry + */ + 'serviceManifest', + /** + * Defines the manifest variables Yaml files to be used to replace variable references in manifest. This parameter + * is optional and will default to `["manifest-variables.yml"]`. This can be used to set variable files like it + * is provided by `cf push --vars-file `. + * + * If the manifest is present and so are all variable files, a variable substitution will be triggered that uses + * the `cfManifestSubstituteVariables` step before deployment. The format of variable references follows the + * [Cloud Foundry standard](https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#variable-substitution). + * @parentConfigKey cloudFoundry + */ + 'manifestVariablesFiles', + /** + * Defines a `List` of variables as key-value `Map` objects used for variable substitution within the file given by `manifest`. + * Defaults to an empty list, if not specified otherwise. This can be used to set variables like it is provided + * by `cf push --var key=value`. + * + * The order of the maps of variables given in the list is relevant in case there are conflicting variable names and values + * between maps contained within the list. In case of conflicts, the last specified map in the list will win. + * + * Though each map entry in the list can contain more than one key-value pair for variable substitution, it is recommended + * to stick to one entry per map, and rather declare more maps within the list. The reason is that + * if a map in the list contains more than one key-value entry, and the entries are conflicting, the + * conflict resolution behavior is undefined (since map entries have no sequence). + * + * Note: variables defined via `manifestVariables` always win over conflicting variables defined via any file given + * by `manifestVariablesFiles` - no matter what is declared before. This is the same behavior as can be + * observed when using `cf push --var` in combination with `cf push --vars-file`. + */ + 'manifestVariables', + /** + * Cloud Foundry target organization. + * @parentConfigKey cloudFoundry + */ + 'org', + /** + * Cloud Foundry target space. + * @parentConfigKey cloudFoundry + */ + 'space', + /** @see dockerExecute */ + 'dockerImage', + /** @see dockerExecute */ + 'dockerWorkspace', + /** @see dockerExecute */ + 'stashContent' +] + +@Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', serviceManifest: 'cfServiceManifest', manifestVariablesFiles: 'cfManifestVariablesFiles', manifestVariables: 'cfManifestVariables', org: 'cfOrg', space: 'cfSpace']] +@Field Set GENERAL_CONFIG_KEYS = STEP_CONFIG_KEYS +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +/** + * Uses the Create-Service-Push plugin to create services in a Cloud Foundry space. + * + * For details how to specify the services see the [github page of the plugin](https://github.com/dawu415/CF-CLI-Create-Service-Push-Plugin). + * + * The `--no-push` options is always used with the plugin. To deploy the application make use of the cloudFoundryDeploy step! + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { + def script = checkScript(this, parameters) ?: this + def utils = parameters.juStabUtils ?: new Utils() + def jenkinsUtils = parameters.jenkinsUtilsStub ?: new JenkinsUtils() + // load default & individual configuration + Map config = ConfigurationHelper.newInstance(this) + .loadStepDefaults() + .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('cloudFoundry/org') + .withMandatoryProperty('cloudFoundry/space') + .withMandatoryProperty('cloudFoundry/credentialsId') + .withMandatoryProperty('cloudFoundry/serviceManifest') + .use() + + + utils.pushToSWA([step: STEP_NAME],config) + + utils.unstashAll(config.stashContent) + + if (fileExists(config.cloudFoundry.serviceManifest)) { + executeCreateServicePush(script, config) + } + } +} + +private def executeCreateServicePush(script, Map config) { + dockerExecute(script:script,dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace) { + + String varPart = varOptions(config) + + String varFilePart = varFileOptions(config) + + withCredentials([ + usernamePassword(credentialsId: config.cloudFoundry.credentialsId, passwordVariable: 'CF_PASSWORD', usernameVariable: 'CF_USERNAME') + ]) { + def returnCode = sh returnStatus: true, script: """#!/bin/bash + set +x + set -e + export HOME=${config.dockerWorkspace} + cf login -u ${BashUtils.quoteAndEscape(CF_USERNAME)} -p ${BashUtils.quoteAndEscape(CF_PASSWORD)} -a ${config.cloudFoundry.apiEndpoint} -o ${BashUtils.quoteAndEscape(config.cloudFoundry.org)} -s ${BashUtils.quoteAndEscape(config.cloudFoundry.space)}; + cf create-service-push --no-push -f ${BashUtils.quoteAndEscape(config.cloudFoundry.serviceManifest)}${varPart}${varFilePart} + """ + sh "cf logout" + if (returnCode!=0) { + error "[${STEP_NAME}] ERROR: The execution of the create-service-push plugin failed, see the logs above for more details." + } + } + } +} + +private varOptions(Map config) { + String varPart = '' + if (config.cloudFoundry.manifestVariables) { + if (!(config.cloudFoundry.manifestVariables in List)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariables is not a List!" + } + config.cloudFoundry.manifestVariables.each { + if (!(it in Map)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariables.$it is not a Map!" + } + it.keySet().each { varKey -> + String varValue=BashUtils.quoteAndEscape(it.get(varKey).toString()) + varPart += " --var $varKey=$varValue" + } + } + } + if (varPart) echo "We will add the following string to the cf push call: '$varPart'" + return varPart +} + +private String varFileOptions(Map config) { + String varFilePart = '' + if (config.cloudFoundry.manifestVariablesFiles) { + if (!(config.cloudFoundry.manifestVariablesFiles in List)) { + error "[${STEP_NAME}] ERROR: Parameter config.cloudFoundry.manifestVariablesFiles is not a List!" + } + config.cloudFoundry.manifestVariablesFiles.each { + if (fileExists(it)) { + varFilePart += " --vars-file ${BashUtils.quoteAndEscape(it)}" + } else { + echo "[${STEP_NAME}] [WARNING] We skip adding not-existing file '$it' as a vars-file to the cf create-service-push call" + } + } + } + if (varFilePart) echo "We will add the following string to the cf push call: '$varFilePart'" + return varFilePart +} From 514755e4efc86d9ed84c389fae36918ea463e175 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 22 Oct 2019 12:28:43 +0200 Subject: [PATCH 072/141] Fail early if file which should be uploaded does not exist. (#909) Right now we fail with some error message from curl. --- test/groovy/TmsUploadTest.groovy | 26 ++++++++++++++++++++++++++ vars/tmsUpload.groovy | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/test/groovy/TmsUploadTest.groovy b/test/groovy/TmsUploadTest.groovy index e16ecb895..0dfb4cfe4 100644 --- a/test/groovy/TmsUploadTest.groovy +++ b/test/groovy/TmsUploadTest.groovy @@ -1,5 +1,8 @@ import com.sap.piper.JenkinsUtils import com.sap.piper.integration.TransportManagementService + +import hudson.AbortException + import org.junit.After import org.junit.Before import org.junit.Rule @@ -19,6 +22,7 @@ public class TmsUploadTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) private JenkinsEnvironmentRule envRule = new JenkinsEnvironmentRule(this) + private JenkinsFileExistsRule fileExistsRules = new JenkinsFileExistsRule(this, ['dummy.mtar']) def tmsStub def jenkinsUtilsStub @@ -56,6 +60,7 @@ public class TmsUploadTest extends BasePiperTest { .around(stepRule) .around(loggingRule) .around(envRule) + .around(fileExistsRules) .around(new JenkinsCredentialsRule(this) .withCredentials('TMS_ServiceKey', serviceKeyContent)) @@ -161,6 +166,27 @@ public class TmsUploadTest extends BasePiperTest { assertThat(loggingRule.log, containsString("[TransportManagementService] Corresponding Transport Request: 'My custom description for testing.' (Id: '2000')")) } + @Test + public void failOnMissingMtaFile() { + + thrown.expect(AbortException) + thrown.expectMessage('Mta file \'dummy.mtar\' does not exist.') + + fileExistsRules.existingFiles.remove('dummy.mtar') + jenkinsUtilsStub = new JenkinsUtilsMock("Test User") + + stepRule.step.tmsUpload( + script: nullScript, + juStabUtils: utils, + jenkinsUtilsStub: jenkinsUtilsStub, + transportManagementService: tmsStub, + mtaPath: 'dummy.mtar', + nodeName: 'myNode', + credentialsId: 'TMS_ServiceKey', + customDescription: 'My custom description for testing.' + ) + } + def mockTransportManagementService() { return new TransportManagementService(nullScript, [:]) { def authentication(String uaaUrl, String oauthClientId, String oauthClientSecret) { diff --git a/vars/tmsUpload.groovy b/vars/tmsUpload.groovy index 6dc77ae5a..dc9e88d7a 100644 --- a/vars/tmsUpload.groovy +++ b/vars/tmsUpload.groovy @@ -94,6 +94,10 @@ void call(Map parameters = [:]) { def nodeName = config.nodeName def mtaPath = config.mtaPath + if(!fileExists(mtaPath)) { + error("Mta file '${mtaPath}' does not exist.") + } + if (config.verbose) { echo "[TransportManagementService] CredentialsId: '${config.credentialsId}'" echo "[TransportManagementService] Node name: '${nodeName}'" From 8e987c46e16b3973ad74acc6d307dbc1932ad538 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 22 Oct 2019 13:53:08 +0200 Subject: [PATCH 073/141] [refactoring] condence common coding for cf deploy (#895) * [refactoring] condence common coding for cf deploy Small change beyond refactoring: for mtaDeploy the user is now quoted. * more general name: logoutAction -> postDeployAction --- test/groovy/CloudFoundryDeployTest.groovy | 4 +- vars/cloudFoundryDeploy.groovy | 58 +++++++---------------- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index e45cb522c..0bbd37afb 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -471,7 +471,7 @@ class CloudFoundryDeployTest extends BasePiperTest { // asserts assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) - assertThat(shellRule.shell, hasItem(containsString('cf login -u test_cf -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) + assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) assertThat(shellRule.shell, hasItem(containsString('cf deploy target/test.mtar -f'))) assertThat(shellRule.shell, hasItem(containsString('cf logout'))) } @@ -491,7 +491,7 @@ class CloudFoundryDeployTest extends BasePiperTest { mtaPath: 'target/test.mtar' ]) - assertThat(shellRule.shell, hasItem(stringContainsInOrder(["cf login -u test_cf", 'cf bg-deploy', '-f', '--no-confirm']))) + assertThat(shellRule.shell, hasItem(stringContainsInOrder(["cf login -u \"test_cf\"", 'cf bg-deploy', '-f', '--no-confirm']))) } @Test diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index 6d31d3aca..5b5126e16 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -269,46 +269,11 @@ def deployMta (config) { } } - withCredentials([usernamePassword( - credentialsId: config.cloudFoundry.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username' - )]) { - echo "[${STEP_NAME}] Deploying MTA (${config.mtaPath}) with following parameters: ${config.mtaExtensionDescriptor} ${config.mtaDeployParameters}" - def cfTraceFile = 'cf.log' - def deployScript = """#!/bin/bash - export HOME=${config.dockerWorkspace} - export CF_TRACE="${cfTraceFile}" - set +x - set -e - cf api ${config.cloudFoundry.apiEndpoint} ${config.apiParameters} - cf login -u ${username} -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" ${config.loginParameters} - cf plugins - cf ${deployCommand} ${config.mtaPath} ${config.mtaDeployParameters} ${config.mtaExtensionDescriptor}""" + def deployStatement = "cf ${deployCommand} ${config.mtaPath} ${config.mtaDeployParameters} ${config.mtaExtensionDescriptor}" + def apiStatement = "cf api ${config.cloudFoundry.apiEndpoint} ${config.apiParameters}" - if(config.verbose) { - // Password contained in output below is hidden by withCredentials - echo "[INFO][$STEP_NAME] Executing deploy command '${deployScript}'" - } - - def returnCode = sh returnStatus: true, script: deployScript - - if(config.verbose || returnCode != 0) { - if(fileExists(file: cfTraceFile)) { - echo '### START OF CF CLI TRACE OUTPUT ###' - // Would be nice to inline the two next lines, but that is not understood by the test framework - def cfTrace = readFile(file: cfTraceFile) - echo cfTrace - echo '### END OF CF CLI TRACE OUTPUT ###' - } else { - echo "No trace file found at '${cfTraceFile}'" - } - } - if(returnCode != 0){ - error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." - } - sh "cf logout" - } + echo "[${STEP_NAME}] Deploying MTA (${config.mtaPath}) with following parameters: ${config.mtaExtensionDescriptor} ${config.mtaDeployParameters}" + deploy(apiStatement, deployStatement, config, null) } private void handleCFNativeDeployment(Map config, script) { @@ -435,6 +400,12 @@ private checkIfAppNameIsAvailable(config) { } def deployCfNative (config) { + def deployStatement = "cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} ${config.cfNativeDeployParameters}" + deploy(null, deployStatement, config, { c -> stopOldAppIfRunning(c) }) +} + +private deploy(def cfApiStatement, def cfDeployStatement, def config, Closure postDeployAction) { + withCredentials([usernamePassword( credentialsId: config.cloudFoundry.credentialsId, passwordVariable: 'password', @@ -448,16 +419,19 @@ def deployCfNative (config) { set -e export HOME=${config.dockerWorkspace} export CF_TRACE=${cfTraceFile} + ${cfApiStatement ?: ''} cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" ${config.loginParameters} cf plugins - cf ${config.deployCommand} ${config.cloudFoundry.appName ?: ''} ${config.deployOptions?:''} -f '${config.cloudFoundry.manifest}' ${config.smokeTest} ${config.cfNativeDeployParameters} + ${cfDeployStatement} """ if(config.verbose) { // Password contained in output below is hidden by withCredentials echo "[INFO][${STEP_NAME}] Executing command: '${deployScript}'." } + def returnCode = sh returnStatus: true, script: deployScript + if(config.verbose || returnCode != 0) { if(fileExists(file: cfTraceFile)) { echo '### START OF CF CLI TRACE OUTPUT ###' @@ -473,7 +447,9 @@ def deployCfNative (config) { if(returnCode != 0){ error "[${STEP_NAME}] ERROR: The execution of the deploy command failed, see the log for details." } - stopOldAppIfRunning(config) + + if(postDeployAction) postDeployAction(config) + sh "cf logout" } } From c1eb9f5c70805e005f879b00b37f23ce952adb29 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Tue, 22 Oct 2019 15:41:27 +0200 Subject: [PATCH 074/141] Provide first parts for golang implementation (#905) * Provide first parts for golang implementation --- .codeclimate.yml | 6 + .editorconfig | 3 + .gitignore | 2 + .travis.yml | 4 +- DEVELOPMENT.md | 131 ++++++++++++++++++ Dockerfile | 14 ++ go.mod | 11 ++ go.sum | 42 ++++++ pkg/config/config.go | 189 +++++++++++++++++++++++++ pkg/config/config_test.go | 253 ++++++++++++++++++++++++++++++++++ pkg/config/defaults.go | 40 ++++++ pkg/config/defaults_test.go | 53 +++++++ pkg/config/errors.go | 18 +++ pkg/config/errors_test.go | 13 ++ pkg/config/flags.go | 43 ++++++ pkg/config/flags_test.go | 67 +++++++++ pkg/config/stepmeta.go | 139 +++++++++++++++++++ pkg/config/stepmeta_test.go | 266 ++++++++++++++++++++++++++++++++++++ 18 files changed, 1293 insertions(+), 1 deletion(-) create mode 100644 DEVELOPMENT.md create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/defaults.go create mode 100644 pkg/config/defaults_test.go create mode 100644 pkg/config/errors.go create mode 100644 pkg/config/errors_test.go create mode 100644 pkg/config/flags.go create mode 100644 pkg/config/flags_test.go create mode 100644 pkg/config/stepmeta.go create mode 100644 pkg/config/stepmeta_test.go diff --git a/.codeclimate.yml b/.codeclimate.yml index a7d27c56a..3fa6325e8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -18,6 +18,12 @@ plugins: strings: - TODO - FIXME + gofmt: + enabled: true + golint: + enabled: true + govet: + enabled: true markdownlint: enabled: true checks: diff --git a/.editorconfig b/.editorconfig index b7d3f9493..37225c59b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,3 +22,6 @@ indent_size = none [cfg/id_rsa.enc] indent_style = none indent_size = none +[{go.mod,go.sum,*.go}] +indent_style = tab +indent_size = 8 diff --git a/.gitignore b/.gitignore index d779c170c..4a5fb35c2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ targets/ documentation/docs-gen consumer-test/**/workspace + +*.code-workspace diff --git a/.travis.yml b/.travis.yml index d605ef1b3..f902be465 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,9 @@ jobs: - curl -L --output cc-test-reporter https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 - chmod +x ./cc-test-reporter - ./cc-test-reporter before-build - script: mvn package --batch-mode + script: + - docker build -t piper:latest . + - mvn package --batch-mode after_script: - JACOCO_SOURCE_PATH="src vars test" ./cc-test-reporter format-coverage target/site/jacoco/jacoco.xml --input-type jacoco - ./cc-test-reporter upload-coverage diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..25cc69252 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,131 @@ +# Development + +**Table of contents:** + +1. [Getting started](#getting-started) +1. [Build the project](#build-the-project_) +1. [Logging](#logging) +1. [Error handling](#error-handling) + +## Getting started + +1. [Ramp up your development environment](#ramp-up) +1. [Get familiar with Go language](#go-basics) +1. Create [a GitHub account](https://github.com/join) +1. Setup [GitHub access via SSH](https://help.github.com/articles/connecting-to-github-with-ssh/) +1. [Create and checkout a repo fork](#checkout-your-fork) +1. Optional: [Get Jenkins related environment](#jenkins-environment) +1. Optional: [Get familiar with Jenkins Pipelines as Code](#jenkins-pipelines) + +### Ramp up + +First you need to set up an appropriate development environment: + +Install Go, see [GO Getting Started](https://golang.org/doc/install) + +Install an IDE with Go plugins, see for example [Go in Visual Studio Code](https://code.visualstudio.com/docs/languages/go) + +### Go basics + +In order to get yourself started, there is a lot of useful information out there. + +As a first step to take we highly recommend the [Golang documentation](https://golang.org/doc/) especially, [A Tour of Go](https://tour.golang.org/welcome/1) + +We have a strong focus on high quality software and contributions without adequate tests will not be accepted. +There is an excellent resource which teaches Go using a test-driven approach: [Learn Go with Tests](https://github.com/quii/learn-go-with-tests) + +### Checkout your fork + +The project uses [Go modules](https://blog.golang.org/using-go-modules). Thus please make sure to **NOT** checkout the project into your [`GOPATH`](https://github.com/golang/go/wiki/SettingGOPATH). + +To check out this repository: + +1. Create your own + [fork of this repo](https://help.github.com/articles/fork-a-repo/) +1. Clone it to your machine, for example like: + +```shell +mkdir -p ${HOME}/projects/jenkins-library +cd ${HOME}/projects +git clone git@github.com:${YOUR_GITHUB_USERNAME}/jenkins-library.git +cd jenkins-library +git remote add upstream git@github.com:sap/jenkins-library.git +git remote set-url --push upstream no_push +``` + +### Jenkins environment + +If you want to contribute also to the Jenkins-specific parts like + +* Jenkins library step +* Jenkins pipeline integration + +you need to do the following in addition: + +* [Install Groovy](https://groovy-lang.org/install.html) +* [Install Maven](https://maven.apache.org/install.html) +* Get a local Jenkins installed: Use for example [cx-server](toDo: add link) + +### Jenkins pipelines + +The Jenkins related parts depend on + +* [Jenkins Pipelines as Code](https://jenkins.io/doc/book/pipeline-as-code/) +* [Jenkins Shared Libraries](https://jenkins.io/doc/book/pipeline/shared-libraries/) + +You should get familiar with these concepts for contributing to the Jenkins-specific parts. + +## Build the project + +### Build the executable suitable for the CI/CD Linux target environments + +Use Docker: + +`docker build -t piper:latest .` + +You can extract the binary using Docker means to your local filesystem: + +``` +docker create --name piper piper:latest +docker cp piper:/piper . +docker rm piper +``` + +## Generating step framework + +The steps are generated based on the yaml files in `resources/metadata/` with the following command +`go run pkg/generator/step-metadata.go`. + +The yaml format is kept pretty close to Tekton's [task format](https://github.com/tektoncd/pipeline/blob/master/docs/tasks.md). +Where the Tekton format was not sufficient some extenstions have been made. + +Examples are: + +* matadata - longDescription +* spec - inputs - secrets +* spec - containers +* spec - sidecars + +## Logging + +to be added + +## Error handling + +In order to better understand the root cause of errors that occur we wrap errors like + +```golang + f, err := os.Open(path) + if err != nil { + return errors.Wrapf(err, "open failed for %v", path) + } + defer f.Close() +``` + +We use [github.com/pkg/errors](https://github.com/pkg/errors) for that. + +## Testing + +Unit tests are done using basic `golang` means. + +Additionally we encourage you to use [github.com/stretchr/testify/assert](https://github.com/stretchr/testify/assert) in order to have slimmer assertions if you like. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..a5a2eebf5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.13 AS build-env +COPY . /build +WORKDIR /build + +# execute tests +RUN go test ./... -cover + +## ONLY tests so far, building to be added later +# execute build +# RUN go build -o piper + +# FROM gcr.io/distroless/base:latest +# COPY --from=build-env /build/piper /piper +# ENTRYPOINT ["/piper"] diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..2c87f65d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/SAP/jenkins-library + +go 1.13 + +require ( + github.com/ghodss/yaml v1.0.0 + github.com/pkg/errors v0.8.1 + github.com/spf13/cobra v0.0.5 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.2.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..796e8ee0a --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 000000000..7dae04d83 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,189 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" +) + +// Config defines the structure of the config files +type Config struct { + General map[string]interface{} `json:"general"` + Stages map[string]map[string]interface{} `json:"stages"` + Steps map[string]map[string]interface{} `json:"steps"` +} + +// StepConfig defines the structure for merged step configuration +type StepConfig struct { + Config map[string]interface{} +} + +// ReadConfig loads config and returns its content +func (c *Config) ReadConfig(configuration io.ReadCloser) error { + defer configuration.Close() + + content, err := ioutil.ReadAll(configuration) + if err != nil { + return errors.Wrapf(err, "error reading %v", configuration) + } + + err = yaml.Unmarshal(content, &c) + if err != nil { + return NewParseError(fmt.Sprintf("error unmarshalling %q: %v", content, err)) + } + return nil +} + +// GetStepConfig provides merged step configuration using defaults, config, if available +func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON string, configuration io.ReadCloser, defaults []io.ReadCloser, filters StepFilters, stageName, stepName string) (StepConfig, error) { + var stepConfig StepConfig + var d PipelineDefaults + + if err := c.ReadConfig(configuration); err != nil { + switch err.(type) { + case *ParseError: + return StepConfig{}, errors.Wrap(err, "failed to parse custom pipeline configuration") + default: + //ignoring unavailability of config file since considered optional + } + } + + if err := d.ReadPipelineDefaults(defaults); err != nil { + switch err.(type) { + case *ParseError: + return StepConfig{}, errors.Wrap(err, "failed to parse pipeline default configuration") + default: + //ignoring unavailability of defaults since considered optional + } + } + + // first: read defaults & merge general -> steps (-> general -> steps ...) + for _, def := range d.Defaults { + stepConfig.mixIn(def.General, filters.General) + stepConfig.mixIn(def.Steps[stepName], filters.Steps) + } + + // second: read config & merge - general -> steps -> stages + stepConfig.mixIn(c.General, filters.General) + stepConfig.mixIn(c.Steps[stepName], filters.Steps) + stepConfig.mixIn(c.Stages[stageName], filters.Stages) + + // third: merge parameters provided via env vars + stepConfig.mixIn(envValues(filters.All), filters.All) + + // fourth: if parameters are provided in JSON format merge them + if len(paramJSON) != 0 { + var params map[string]interface{} + json.Unmarshal([]byte(paramJSON), ¶ms) + stepConfig.mixIn(params, filters.Parameters) + } + + // fifth: merge command line flags + if flagValues != nil { + stepConfig.mixIn(flagValues, filters.Parameters) + } + + return stepConfig, nil +} + +// GetStepConfigWithJSON provides merged step configuration using a provided stepConfigJSON with additional flags provided +func GetStepConfigWithJSON(flagValues map[string]interface{}, stepConfigJSON string, filters StepFilters) StepConfig { + var stepConfig StepConfig + + stepConfigMap := map[string]interface{}{} + + json.Unmarshal([]byte(stepConfigJSON), &stepConfigMap) + + stepConfig.mixIn(stepConfigMap, filters.All) + + // ToDo: mix in parametersJSON + + if flagValues != nil { + stepConfig.mixIn(flagValues, filters.Parameters) + } + return stepConfig +} + +// GetJSON returns JSON representation of an object +func GetJSON(data interface{}) (string, error) { + + result, err := json.Marshal(data) + if err != nil { + return "", errors.Wrapf(err, "error marshalling json: %v", err) + } + return string(result), nil +} + +func envValues(filter []string) map[string]interface{} { + vals := map[string]interface{}{} + for _, param := range filter { + if envVal := os.Getenv("PIPER_" + param); len(envVal) != 0 { + vals[param] = os.Getenv("PIPER_" + param) + } + } + return vals +} + +func (s *StepConfig) mixIn(mergeData map[string]interface{}, filter []string) { + + if s.Config == nil { + s.Config = map[string]interface{}{} + } + + s.Config = filterMap(merge(s.Config, mergeData), filter) +} + +func filterMap(data map[string]interface{}, filter []string) map[string]interface{} { + result := map[string]interface{}{} + + if data == nil { + data = map[string]interface{}{} + } + + for key, value := range data { + if len(filter) == 0 || sliceContains(filter, key) { + result[key] = value + } + } + return result +} + +func merge(base, overlay map[string]interface{}) map[string]interface{} { + + result := map[string]interface{}{} + + if base == nil { + base = map[string]interface{}{} + } + + for key, value := range base { + result[key] = value + } + + for key, value := range overlay { + if val, ok := value.(map[string]interface{}); ok { + if valBaseKey, ok := base[key].(map[string]interface{}); !ok { + result[key] = merge(map[string]interface{}{}, val) + } else { + result[key] = merge(valBaseKey, val) + } + } else { + result[key] = value + } + } + return result +} + +func sliceContains(slice []string, find string) bool { + for _, elem := range slice { + if elem == find { + return true + } + } + return false +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..7e9a2aedb --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,253 @@ +package config + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type errReadCloser int + +func (errReadCloser) Read(p []byte) (n int, err error) { + return 0, errors.New("read error") +} + +func (errReadCloser) Close() error { + return nil +} + +func TestReadConfig(t *testing.T) { + + var c Config + + t.Run("Success case", func(t *testing.T) { + + myConfig := strings.NewReader("general:\n generalTestKey: generalTestValue\nsteps:\n testStep:\n testStepKey: testStepValue") + + err := c.ReadConfig(ioutil.NopCloser(myConfig)) // NopCloser "no-ops" the closing interface since strings do not need to be closed + if err != nil { + t.Errorf("Got error although no error expected: %v", err) + } + + if c.General["generalTestKey"] != "generalTestValue" { + t.Errorf("General config- got: %v, expected: %v", c.General["generalTestKey"], "generalTestValue") + } + + if c.Steps["testStep"]["testStepKey"] != "testStepValue" { + t.Errorf("Step config - got: %v, expected: %v", c.Steps["testStep"]["testStepKey"], "testStepValue") + } + }) + + t.Run("Read failure", func(t *testing.T) { + var rc errReadCloser + err := c.ReadConfig(rc) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) + + t.Run("Unmarshalling failure", func(t *testing.T) { + myConfig := strings.NewReader("general:\n generalTestKey: generalTestValue\nsteps:\n testStep:\n\ttestStepKey: testStepValue") + err := c.ReadConfig(ioutil.NopCloser(myConfig)) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) + +} + +func TestGetStepConfig(t *testing.T) { + + t.Run("Success case", func(t *testing.T) { + + testConfig := `general: + p3: p3_general + px3: px3_general + p4: p4_general +steps: + step1: + p4: p4_step + px4: px4_step + p5: p5_step +stages: + stage1: + p5: p5_stage + px5: px5_stage + p6: p6_stage +` + filters := StepFilters{ + General: []string{"p0", "p1", "p2", "p3", "p4"}, + Steps: []string{"p0", "p1", "p2", "p3", "p4", "p5"}, + Stages: []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6"}, + Parameters: []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7"}, + Env: []string{"p0", "p1", "p2", "p3", "p4", "p5"}, + } + + defaults1 := `general: + p0: p0_general_default + px0: px0_general_default + p1: p1_general_default +steps: + step1: + p1: p1_step_default + px1: px1_step_default + p2: p2_step_default +` + + defaults2 := `general: + p2: p2_general_default + px2: px2_general_default + p3: p3_general_default +` + paramJSON := `{"p6":"p6_param","p7":"p7_param"}` + + flags := map[string]interface{}{"p7": "p7_flag"} + + var c Config + defaults := []io.ReadCloser{ioutil.NopCloser(strings.NewReader(defaults1)), ioutil.NopCloser(strings.NewReader(defaults2))} + + myConfig := ioutil.NopCloser(strings.NewReader(testConfig)) + stepConfig, err := c.GetStepConfig(flags, paramJSON, myConfig, defaults, filters, "stage1", "step1") + + assert.Equal(t, nil, err, "error occured but none expected") + + t.Run("Config", func(t *testing.T) { + expected := map[string]string{ + "p0": "p0_general_default", + "p1": "p1_step_default", + "p2": "p2_general_default", + "p3": "p3_general", + "p4": "p4_step", + "p5": "p5_stage", + "p6": "p6_param", + "p7": "p7_flag", + } + for k, v := range expected { + t.Run(k, func(t *testing.T) { + if stepConfig.Config[k] != v { + t.Errorf("got: %v, expected: %v", stepConfig.Config[k], v) + } + }) + } + }) + + t.Run("Config not expected", func(t *testing.T) { + notExpectedKeys := []string{"px0", "px1", "px2", "px3", "px4", "px5"} + for _, p := range notExpectedKeys { + t.Run(p, func(t *testing.T) { + if stepConfig.Config[p] != nil { + t.Errorf("unexpected: %v", p) + } + }) + } + }) + }) + + t.Run("Failure case config", func(t *testing.T) { + var c Config + myConfig := ioutil.NopCloser(strings.NewReader("invalid config")) + _, err := c.GetStepConfig(nil, "", myConfig, nil, StepFilters{}, "stage1", "step1") + assert.EqualError(t, err, "failed to parse custom pipeline configuration: error unmarshalling \"invalid config\": error unmarshaling JSON: json: cannot unmarshal string into Go value of type config.Config", "default error expected") + }) + + t.Run("Failure case defaults", func(t *testing.T) { + var c Config + myConfig := ioutil.NopCloser(strings.NewReader("")) + myDefaults := []io.ReadCloser{ioutil.NopCloser(strings.NewReader("invalid defaults"))} + _, err := c.GetStepConfig(nil, "", myConfig, myDefaults, StepFilters{}, "stage1", "step1") + assert.EqualError(t, err, "failed to parse pipeline default configuration: error unmarshalling \"invalid defaults\": error unmarshaling JSON: json: cannot unmarshal string into Go value of type config.Config", "default error expected") + }) + + //ToDo: test merging of env and parameters/flags +} + +func TestGetStepConfigWithJSON(t *testing.T) { + + filters := StepFilters{All: []string{"key1"}} + + t.Run("Without flags", func(t *testing.T) { + sc := GetStepConfigWithJSON(nil, `"key1":"value1","key2":"value2"`, filters) + + if sc.Config["key1"] != "value1" && sc.Config["key2"] == "value2" { + t.Errorf("got: %v, expected: %v", sc.Config, StepConfig{Config: map[string]interface{}{"key1": "value1"}}) + } + }) + + t.Run("With flags", func(t *testing.T) { + flags := map[string]interface{}{"key1": "flagVal1"} + sc := GetStepConfigWithJSON(flags, `"key1":"value1","key2":"value2"`, filters) + if sc.Config["key1"] != "flagVal1" { + t.Errorf("got: %v, expected: %v", sc.Config["key1"], "flagVal1") + } + }) +} + +func TestGetJSON(t *testing.T) { + + t.Run("Success case", func(t *testing.T) { + custom := map[string]interface{}{"key1": "value1"} + json, err := GetJSON(custom) + if err != nil { + t.Errorf("Got error although no error expected: %v", err) + } + + if json != `{"key1":"value1"}` { + t.Errorf("got: %v, expected: %v", json, `{"key1":"value1"}`) + } + + }) + t.Run("Marshalling failure", func(t *testing.T) { + _, err := GetJSON(make(chan int)) + if err == nil { + t.Errorf("Got no error although error expected") + } + }) +} + +func TestMerge(t *testing.T) { + + testTable := []struct { + Source map[string]interface{} + Filter []string + MergeData map[string]interface{} + ExpectedOutput map[string]interface{} + }{ + { + Source: map[string]interface{}{"key1": "baseValue"}, + Filter: []string{}, + MergeData: map[string]interface{}{"key1": "overwrittenValue"}, + ExpectedOutput: map[string]interface{}{"key1": "overwrittenValue"}, + }, + { + Source: map[string]interface{}{"key1": "value1"}, + Filter: []string{}, + MergeData: map[string]interface{}{"key2": "value2"}, + ExpectedOutput: map[string]interface{}{"key1": "value1", "key2": "value2"}, + }, + { + Source: map[string]interface{}{"key1": "value1"}, + Filter: []string{"key1"}, + MergeData: map[string]interface{}{"key2": "value2"}, + ExpectedOutput: map[string]interface{}{"key1": "value1"}, + }, + { + Source: map[string]interface{}{"key1": map[string]interface{}{"key1_1": "value1"}}, + Filter: []string{}, + MergeData: map[string]interface{}{"key1": map[string]interface{}{"key1_2": "value2"}}, + ExpectedOutput: map[string]interface{}{"key1": map[string]interface{}{"key1_1": "value1", "key1_2": "value2"}}, + }, + } + + for _, row := range testTable { + t.Run(fmt.Sprintf("Merging %v into %v", row.MergeData, row.Source), func(t *testing.T) { + stepConfig := StepConfig{Config: row.Source} + stepConfig.mixIn(row.MergeData, row.Filter) + assert.Equal(t, row.ExpectedOutput, stepConfig.Config, "Mixin was incorrect") + }) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go new file mode 100644 index 000000000..f9e60b002 --- /dev/null +++ b/pkg/config/defaults.go @@ -0,0 +1,40 @@ +package config + +import ( + "fmt" + "io" + "io/ioutil" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" +) + +// PipelineDefaults defines the structure of the pipeline defaults +type PipelineDefaults struct { + Defaults []Config `json:"defaults"` +} + +// ReadPipelineDefaults loads defaults and returns its content +func (d *PipelineDefaults) ReadPipelineDefaults(defaultSources []io.ReadCloser) error { + + for _, def := range defaultSources { + + defer def.Close() + + var c Config + var err error + + content, err := ioutil.ReadAll(def) + if err != nil { + return errors.Wrapf(err, "error reading %v", def) + } + + err = yaml.Unmarshal(content, &c) + if err != nil { + return NewParseError(fmt.Sprintf("error unmarshalling %q: %v", content, err)) + } + + d.Defaults = append(d.Defaults, c) + } + return nil +} diff --git a/pkg/config/defaults_test.go b/pkg/config/defaults_test.go new file mode 100644 index 000000000..fbe26d141 --- /dev/null +++ b/pkg/config/defaults_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "io" + "io/ioutil" + "strings" + "testing" +) + +func TestReadPipelineDefaults(t *testing.T) { + + var d PipelineDefaults + + t.Run("Success case", func(t *testing.T) { + d0 := strings.NewReader("general:\n testStepKey1: testStepValue1") + d1 := strings.NewReader("general:\n testStepKey2: testStepValue2") + err := d.ReadPipelineDefaults([]io.ReadCloser{ioutil.NopCloser(d0), ioutil.NopCloser(d1)}) + + if err != nil { + t.Errorf("Got error although no error expected: %v", err) + } + + t.Run("Defaults 0", func(t *testing.T) { + expected := "testStepValue1" + if d.Defaults[0].General["testStepKey1"] != expected { + t.Errorf("got: %v, expected: %v", d.Defaults[0].General["testStepKey1"], expected) + } + }) + + t.Run("Defaults 1", func(t *testing.T) { + expected := "testStepValue2" + if d.Defaults[1].General["testStepKey2"] != expected { + t.Errorf("got: %v, expected: %v", d.Defaults[1].General["testStepKey2"], expected) + } + }) + }) + + t.Run("Read failure", func(t *testing.T) { + var rc errReadCloser + err := d.ReadPipelineDefaults([]io.ReadCloser{rc}) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) + + t.Run("Unmarshalling failure", func(t *testing.T) { + myConfig := strings.NewReader("general:\n\ttestStepKey: testStepValue") + err := d.ReadPipelineDefaults([]io.ReadCloser{ioutil.NopCloser(myConfig)}) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) +} diff --git a/pkg/config/errors.go b/pkg/config/errors.go new file mode 100644 index 000000000..ac34552f1 --- /dev/null +++ b/pkg/config/errors.go @@ -0,0 +1,18 @@ +package config + +// ParseError defines an error type for configuration parsing errors +type ParseError struct { + message string +} + +// NewParseError creates a new ParseError +func NewParseError(message string) *ParseError { + return &ParseError{ + message: message, + } +} + +// Error returns the message of the ParseError +func (e *ParseError) Error() string { + return e.message +} diff --git a/pkg/config/errors_test.go b/pkg/config/errors_test.go new file mode 100644 index 000000000..dc05427e5 --- /dev/null +++ b/pkg/config/errors_test.go @@ -0,0 +1,13 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseError(t *testing.T) { + err := NewParseError("Parsing failed") + + assert.Equal(t, "Parsing failed", err.Error()) +} diff --git a/pkg/config/flags.go b/pkg/config/flags.go new file mode 100644 index 000000000..6ad7c591e --- /dev/null +++ b/pkg/config/flags.go @@ -0,0 +1,43 @@ +package config + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" +) + +// AvailableFlagValues returns all flags incl. values which are available to the command. +func AvailableFlagValues(cmd *cobra.Command, filters *StepFilters) map[string]interface{} { + flagValues := map[string]interface{}{} + flags := cmd.Flags() + //only check flags where value has been set + flags.Visit(func(pflag *flag.Flag) { + + switch pflag.Value.Type() { + case "string": + flagValues[pflag.Name] = pflag.Value.String() + case "stringSlice": + flagValues[pflag.Name], _ = flags.GetStringSlice(pflag.Name) + case "bool": + flagValues[pflag.Name], _ = flags.GetBool(pflag.Name) + default: + fmt.Printf("Meta data type not set or not known: '%v'\n", pflag.Value.Type()) + os.Exit(1) + } + filters.Parameters = append(filters.Parameters, pflag.Name) + }) + return flagValues +} + +// MarkFlagsWithValue marks a flag as changed if value is available for the flag through the step configuration. +func MarkFlagsWithValue(cmd *cobra.Command, stepConfig StepConfig) { + flags := cmd.Flags() + flags.VisitAll(func(pflag *flag.Flag) { + //mark as available in case default is available or config is available + if len(pflag.Value.String()) > 0 || stepConfig.Config[pflag.Name] != nil { + pflag.Changed = true + } + }) +} diff --git a/pkg/config/flags_test.go b/pkg/config/flags_test.go new file mode 100644 index 000000000..39ea31c4a --- /dev/null +++ b/pkg/config/flags_test.go @@ -0,0 +1,67 @@ +package config + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestAvailableFlagValues(t *testing.T) { + var f StepFilters + + var test0 string + var test1 string + var test2 []string + var test3 bool + + var c = &cobra.Command{ + Use: "test", + Short: "..", + } + + c.Flags().StringVar(&test0, "test0", "val0", "Test 0") + c.Flags().StringVar(&test1, "test1", "", "Test 1") + c.Flags().StringSliceVar(&test2, "test2", []string{}, "Test 2") + c.Flags().BoolVar(&test3, "test3", false, "Test 3") + + c.Flags().Set("test1", "val1") + c.Flags().Set("test2", "val3_1") + c.Flags().Set("test3", "true") + + v := AvailableFlagValues(c, &f) + + if v["test0"] != nil { + t.Errorf("expected: 'test0' to be empty but was %v", v["test0"]) + } + + assert.Equal(t, "val1", v["test1"]) + assert.Equal(t, []string{"val3_1"}, v["test2"]) + assert.Equal(t, true, v["test3"]) + +} + +func TestMarkFlagsWithValue(t *testing.T) { + var test0 string + var test1 string + var test2 string + var c = &cobra.Command{ + Use: "test", + Short: "..", + } + c.Flags().StringVar(&test0, "test0", "val0", "Test 0") + c.Flags().StringVar(&test1, "test1", "", "Test 1") + c.Flags().StringVar(&test2, "test2", "", "Test 2") + + s := StepConfig{ + Config: map[string]interface{}{ + "test2": "val2", + }, + } + + MarkFlagsWithValue(c, s) + + assert.Equal(t, true, c.Flags().Changed("test0"), "default not considered") + assert.Equal(t, false, c.Flags().Changed("test1"), "no value: considered as set") + assert.Equal(t, true, c.Flags().Changed("test2"), "config not considered") +} diff --git a/pkg/config/stepmeta.go b/pkg/config/stepmeta.go new file mode 100644 index 000000000..22045b987 --- /dev/null +++ b/pkg/config/stepmeta.go @@ -0,0 +1,139 @@ +package config + +import ( + "io" + "io/ioutil" + + "github.com/ghodss/yaml" + "github.com/pkg/errors" +) + +// StepData defines the metadata for a step, like step descriptions, parameters, ... +type StepData struct { + Metadata StepMetadata `json:"metadata"` + Spec StepSpec `json:"spec"` +} + +// StepMetadata defines the metadata for a step, like step descriptions, parameters, ... +type StepMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + LongDescription string `json:"longDescription,omitempty"` +} + +// StepSpec defines the spec details for a step, like step inputs, containers, sidecars, ... +type StepSpec struct { + Inputs StepInputs `json:"inputs"` + // Outputs string `json:"description,omitempty"` + Containers []StepContainers `json:"containers,omitempty"` + Sidecars []StepSidecars `json:"sidecars,omitempty"` +} + +// StepInputs defines the spec details for a step, like step inputs, containers, sidecars, ... +type StepInputs struct { + Parameters []StepParameters `json:"params"` + Resources []StepResources `json:"resources,omitempty"` + Secrets []StepSecrets `json:"secrets,omitempty"` +} + +// StepParameters defines the parameters for a step +type StepParameters struct { + Name string `json:"name"` + Description string `json:"description"` + LongDescription string `json:"longDescription,omitempty"` + Scope []string `json:"scope"` + Type string `json:"type"` + Mandatory bool `json:"mandatory,omitempty"` + Default interface{} `json:"default,omitempty"` +} + +// StepResources defines the resources to be provided by the step context, e.g. Jenkins pipeline +type StepResources struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// StepSecrets defines the secrets to be provided by the step context, e.g. Jenkins pipeline +type StepSecrets struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// StepOutputs defines the outputs of a step +//type StepOutputs struct { +// Name string `json:"name"` +//} + +// StepContainers defines the containers required for a step +type StepContainers struct { + Containers map[string]interface{} `json:"containers"` +} + +// StepSidecars defines any sidears required for a step +type StepSidecars struct { + Sidecars map[string]interface{} `json:"sidecars"` +} + +// StepFilters defines the filter parameters for the different sections +type StepFilters struct { + All []string + General []string + Stages []string + Steps []string + Parameters []string + Env []string +} + +// ReadPipelineStepData loads step definition in yaml format +func (m *StepData) ReadPipelineStepData(metadata io.ReadCloser) error { + defer metadata.Close() + content, err := ioutil.ReadAll(metadata) + if err != nil { + return errors.Wrapf(err, "error reading %v", metadata) + } + + err = yaml.Unmarshal(content, &m) + if err != nil { + return errors.Wrapf(err, "error unmarshalling: %v", err) + } + return nil +} + +// GetParameterFilters retrieves all scope dependent parameter filters +func (m *StepData) GetParameterFilters() StepFilters { + var filters StepFilters + for _, param := range m.Spec.Inputs.Parameters { + filters.All = append(filters.All, param.Name) + for _, scope := range param.Scope { + switch scope { + case "GENERAL": + filters.General = append(filters.General, param.Name) + case "STEPS": + filters.Steps = append(filters.Steps, param.Name) + case "STAGES": + filters.Stages = append(filters.Stages, param.Name) + case "PARAMETERS": + filters.Parameters = append(filters.Parameters, param.Name) + case "ENV": + filters.Env = append(filters.Env, param.Name) + } + } + } + return filters +} + +// GetContextParameterFilters retrieves all scope dependent parameter filters +func (m *StepData) GetContextParameterFilters() StepFilters { + var filters StepFilters + for _, secret := range m.Spec.Inputs.Secrets { + filters.All = append(filters.All, secret.Name) + filters.General = append(filters.General, secret.Name) + filters.Steps = append(filters.Steps, secret.Name) + filters.Stages = append(filters.Stages, secret.Name) + filters.Parameters = append(filters.Parameters, secret.Name) + filters.Env = append(filters.Env, secret.Name) + } + return filters +} diff --git a/pkg/config/stepmeta_test.go b/pkg/config/stepmeta_test.go new file mode 100644 index 000000000..307f57f6a --- /dev/null +++ b/pkg/config/stepmeta_test.go @@ -0,0 +1,266 @@ +package config + +import ( + "fmt" + "io/ioutil" + "strings" + "testing" +) + +func TestReadPipelineStepData(t *testing.T) { + var s StepData + + t.Run("Success case", func(t *testing.T) { + myMeta := strings.NewReader("metadata:\n name: testIt\nspec:\n inputs:\n params:\n - name: testParamName\n secrets:\n - name: testSecret") + err := s.ReadPipelineStepData(ioutil.NopCloser(myMeta)) // NopCloser "no-ops" the closing interface since strings do not need to be closed + + if err != nil { + t.Errorf("Got error although no error expected: %v", err) + } + + t.Run("step name", func(t *testing.T) { + if s.Metadata.Name != "testIt" { + t.Errorf("Meta name - got: %v, expected: %v", s.Metadata.Name, "testIt") + } + }) + + t.Run("param name", func(t *testing.T) { + if s.Spec.Inputs.Parameters[0].Name != "testParamName" { + t.Errorf("Step name - got: %v, expected: %v", s.Spec.Inputs.Parameters[0].Name, "testParamName") + } + }) + + t.Run("secret name", func(t *testing.T) { + if s.Spec.Inputs.Secrets[0].Name != "testSecret" { + t.Errorf("Step name - got: %v, expected: %v", s.Spec.Inputs.Secrets[0].Name, "testSecret") + } + }) + }) + + t.Run("Read failure", func(t *testing.T) { + var rc errReadCloser + err := s.ReadPipelineStepData(rc) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) + + t.Run("Unmarshalling failure", func(t *testing.T) { + myMeta := strings.NewReader("metadata:\n\tname: testIt") + err := s.ReadPipelineStepData(ioutil.NopCloser(myMeta)) + if err == nil { + t.Errorf("Got no error although error expected.") + } + }) +} + +func TestGetParameterFilters(t *testing.T) { + metadata1 := StepData{ + Spec: StepSpec{ + Inputs: StepInputs{ + Parameters: []StepParameters{ + {Name: "paramOne", Scope: []string{"GENERAL", "STEPS", "STAGES", "PARAMETERS", "ENV"}}, + {Name: "paramTwo", Scope: []string{"STEPS", "STAGES", "PARAMETERS", "ENV"}}, + {Name: "paramThree", Scope: []string{"STAGES", "PARAMETERS", "ENV"}}, + {Name: "paramFour", Scope: []string{"PARAMETERS", "ENV"}}, + {Name: "paramFive", Scope: []string{"ENV"}}, + {Name: "paramSix"}, + }, + }, + }, + } + + metadata2 := StepData{ + Spec: StepSpec{ + Inputs: StepInputs{ + Parameters: []StepParameters{ + {Name: "paramOne", Scope: []string{"GENERAL"}}, + {Name: "paramTwo", Scope: []string{"STEPS"}}, + {Name: "paramThree", Scope: []string{"STAGES"}}, + {Name: "paramFour", Scope: []string{"PARAMETERS"}}, + {Name: "paramFive", Scope: []string{"ENV"}}, + {Name: "paramSix"}, + }, + }, + }, + } + + metadata3 := StepData{ + Spec: StepSpec{ + Inputs: StepInputs{ + Parameters: []StepParameters{}, + }, + }, + } + + testTable := []struct { + Metadata StepData + ExpectedAll []string + ExpectedGeneral []string + ExpectedStages []string + ExpectedSteps []string + ExpectedParameters []string + ExpectedEnv []string + NotExpectedAll []string + NotExpectedGeneral []string + NotExpectedStages []string + NotExpectedSteps []string + NotExpectedParameters []string + NotExpectedEnv []string + }{ + { + Metadata: metadata1, + ExpectedGeneral: []string{"paramOne"}, + ExpectedSteps: []string{"paramOne", "paramTwo"}, + ExpectedStages: []string{"paramOne", "paramTwo", "paramThree"}, + ExpectedParameters: []string{"paramOne", "paramTwo", "paramThree", "paramFour"}, + ExpectedEnv: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive"}, + ExpectedAll: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedGeneral: []string{"paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedSteps: []string{"paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedStages: []string{"paramFour", "paramFive", "paramSix"}, + NotExpectedParameters: []string{"paramFive", "paramSix"}, + NotExpectedEnv: []string{"paramSix"}, + NotExpectedAll: []string{}, + }, + { + Metadata: metadata2, + ExpectedGeneral: []string{"paramOne"}, + ExpectedSteps: []string{"paramTwo"}, + ExpectedStages: []string{"paramThree"}, + ExpectedParameters: []string{"paramFour"}, + ExpectedEnv: []string{"paramFive"}, + ExpectedAll: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedGeneral: []string{"paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedSteps: []string{"paramOne", "paramThree", "paramFour", "paramFive", "paramSix"}, + NotExpectedStages: []string{"paramOne", "paramTwo", "paramFour", "paramFive", "paramSix"}, + NotExpectedParameters: []string{"paramOne", "paramTwo", "paramThree", "paramFive", "paramSix"}, + NotExpectedEnv: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramSix"}, + NotExpectedAll: []string{}, + }, + { + Metadata: metadata3, + ExpectedGeneral: []string{}, + ExpectedStages: []string{}, + ExpectedSteps: []string{}, + ExpectedParameters: []string{}, + ExpectedEnv: []string{}, + }, + } + + for key, row := range testTable { + t.Run(fmt.Sprintf("Metadata%v", key), func(t *testing.T) { + filters := row.Metadata.GetParameterFilters() + t.Run("General", func(t *testing.T) { + for _, val := range filters.General { + if !sliceContains(row.ExpectedGeneral, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.General) + } + if sliceContains(row.NotExpectedGeneral, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.General) + } + } + }) + t.Run("Steps", func(t *testing.T) { + for _, val := range filters.Steps { + if !sliceContains(row.ExpectedSteps, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.Steps) + } + if sliceContains(row.NotExpectedSteps, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.Steps) + } + } + }) + t.Run("Stages", func(t *testing.T) { + for _, val := range filters.Stages { + if !sliceContains(row.ExpectedStages, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.Stages) + } + if sliceContains(row.NotExpectedStages, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.Stages) + } + } + }) + t.Run("Parameters", func(t *testing.T) { + for _, val := range filters.Parameters { + if !sliceContains(row.ExpectedParameters, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.Parameters) + } + if sliceContains(row.NotExpectedParameters, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.Parameters) + } + } + }) + t.Run("Env", func(t *testing.T) { + for _, val := range filters.Env { + if !sliceContains(row.ExpectedEnv, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.Env) + } + if sliceContains(row.NotExpectedEnv, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.Env) + } + } + }) + t.Run("All", func(t *testing.T) { + for _, val := range filters.All { + if !sliceContains(row.ExpectedAll, val) { + t.Errorf("Creation of parameter filter failed, expected: %v to be contained in %v", val, filters.All) + } + if sliceContains(row.NotExpectedAll, val) { + t.Errorf("Creation of parameter filter failed, expected: %v NOT to be contained in %v", val, filters.All) + } + } + }) + }) + } +} + +func TestGetContextParameterFilters(t *testing.T) { + metadata1 := StepData{ + Spec: StepSpec{ + Inputs: StepInputs{ + Secrets: []StepSecrets{ + {Name: "testSecret1", Type: "jenkins"}, + {Name: "testSecret2", Type: "jenkins"}, + }, + }, + }, + } + + filters := metadata1.GetContextParameterFilters() + + t.Run("Secrets", func(t *testing.T) { + for _, s := range metadata1.Spec.Inputs.Secrets { + t.Run("All", func(t *testing.T) { + if !sliceContains(filters.All, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + t.Run("General", func(t *testing.T) { + if !sliceContains(filters.General, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + t.Run("Step", func(t *testing.T) { + if !sliceContains(filters.Steps, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + t.Run("Stages", func(t *testing.T) { + if !sliceContains(filters.Steps, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + t.Run("Parameters", func(t *testing.T) { + if !sliceContains(filters.Parameters, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + t.Run("Env", func(t *testing.T) { + if !sliceContains(filters.Env, s.Name) { + t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) + } + }) + } + }) +} From 945599c1094fe69f359bc4e360647959f79fcecd Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 23 Oct 2019 11:03:17 +0200 Subject: [PATCH 075/141] docu xsdeploy scenario (#840) --- .../docs/scenarios/xsa-deploy/Readme.md | 101 ++++++++++++++++++ .../scenarios/xsa-deploy/images/pipeline.jpg | Bin 0 -> 18507 bytes documentation/mkdocs.yml | 22 ++++ 3 files changed, 123 insertions(+) create mode 100644 documentation/docs/scenarios/xsa-deploy/Readme.md create mode 100644 documentation/docs/scenarios/xsa-deploy/images/pipeline.jpg diff --git a/documentation/docs/scenarios/xsa-deploy/Readme.md b/documentation/docs/scenarios/xsa-deploy/Readme.md new file mode 100644 index 000000000..81fd6f35c --- /dev/null +++ b/documentation/docs/scenarios/xsa-deploy/Readme.md @@ -0,0 +1,101 @@ +# Build and Deploy SAP Fiori Applications on SAP HANA XS Advanced + +Build an application based on SAPUI5 or SAP Fiori with Jenkins and deploy the build result into an SAP Cloud Platform account in the Neo environment. + +## Prerequisites + +* [Docker environment](https://docs.docker.com/get-started/) +* All artifacts refereneced during the build are available either on Service Market Place or via public repositories +* You have set up Project “Piper”. See [guided tour](https://sap.github.io/jenkins-library/guidedtour/). +* Docker image for xs deployment available. Due to legal reasons there is no pre-build docker image. How to create the docker image is explained [here](https://github.com/SAP/devops-docker-images/tree/master/xs-cli). + +### Project Prerequisites + +This scenario requires additional files in your project and in the execution environment on your Jenkins instance. +For details see: [XSA developer quick start guide](https://help.sap.com/viewer/400066065a1b46cf91df0ab436404ddc/2.0.04/en-US/7f681c32c2a34735ad85e4ab403f8c26.html). + +## Context + +This scenario combines various different steps to create a complete pipeline. + +In this scenario, we want to show how to build a Multitarget Application (MTA) and deploy the build result into an on-prem SAP HANA XS advances system. This document comprises the [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) and the [xsDeploy](https://sap.github.io/jenkins-library/steps/xsDeploy/) steps. + +![This pipeline in Jenkins Blue Ocean](images/pipeline.jpg) +###### Screenshot: Build and Deploy Process in Jenkins + +## Example + +### Jenkinsfile + +Following the convention for pipeline definitions, use a `Jenkinsfile`, which resides in the root directory of your development sources. + +```groovy +@Library('piper-lib-os') _ + +pipeline { + + agent any + + stages { + stage("prepare") { + steps { + deleteDir() + checkout scm + setupCommonPipelineEnvironment script: this + } + } + stage('build') { + steps { + mtaBuild script: this + } + } + stage('deploy') { + steps { + xsDeploy script: this + } + } + } +} +``` + +### Configuration (`.pipeline/config.yml`) + +This is a basic configuration example, which is also located in the sources of the project. + +```yaml +steps: + mtaBuild: + buildTarget: 'XSA' + xsDeploy: + apiUrl: '' # e.g. 'https://example.org:30030' + # credentialsId: 'XS' omitted, 'XS' is the default + docker: + dockerImage: '' + space: '' + +``` + +#### Configuration for the MTA Build + +| Parameter | Description | +| -----------------|----------------| +| `buildTarget` | The target platform to which the mtar can be deployed. In this case, the target platform is `XSA`. | + +#### Configuration for the Deployment to XSA + +| Parameter | Description | +| -------------------|-------------| +| `credentialsId` | The Jenkins credentials that contain user and password required for the deployment on SAP Cloud Platform.| +| `mode` | DeployMode. See [stepDocu](../../../steps/xsDeploy) for more details. | +| `org` | The org. See [stepDocu](../../../steps/xsDeploy) for more details. | +| `space` | The space. See [stepDocu](../../../steps/xsDeploy) for more details. | + +### Parameters + +For the detailed description of the relevant parameters, see: + +* [mtaBuild](https://sap.github.io/jenkins-library/steps/mtaBuild/) +* [xsDeploy](https://sap.github.io/jenkins-library/steps/xsDeploy/) diff --git a/documentation/docs/scenarios/xsa-deploy/images/pipeline.jpg b/documentation/docs/scenarios/xsa-deploy/images/pipeline.jpg new file mode 100644 index 0000000000000000000000000000000000000000..09efda5db572c96a94cb0a02524d459a47740e1c GIT binary patch literal 18507 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 literal 0 HcmV?d00001 diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 7b63bf062..5a7397361 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -78,6 +78,28 @@ nav: - uiVeri5ExecuteTests: steps/uiVeri5ExecuteTests.md - whitesourceExecuteScan: steps/whitesourceExecuteScan.md - xsDeploy: steps/xsDeploy.md + - 'Pipelines': + - 'General purpose pipeline': + - 'Introduction': stages/introduction.md + - 'Examples': stages/examples.md + - 'Stages': + - 'Init Stage': stages/init.md + - 'Pull-Request Voting Stage': stages/prvoting.md + - 'Build Stage': stages/build.md + - 'Additional Unit Test Stage': stages/additionalunittests.md + - 'Integration Stage': stages/integration.md + - 'Acceptance Stage': stages/acceptance.md + - 'Security Stage': stages/security.md + - 'Performance Stage': stages/performance.md + - 'Compliance': stages/compliance.md + - 'Confirm Stage': stages/confirm.md + - 'Promote Stage': stages/promote.md + - 'Release Stage': stages/release.md + - 'Scenarios': + - 'Build and Deploy Hybrid Applications with Jenkins and SAP Solution Manager': scenarios/changeManagement.md + - 'Build and Deploy SAP UI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins': scenarios/ui5-sap-cp/Readme.md + - 'Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model': scenarios/CAP_Scenario.md + - 'Build and Deploy SAP Fiori Applications for SAP HANA XS Advanced ': scenarios/xsa-deploy/Readme.md - Resources: - 'Required Plugins': jenkins/requiredPlugins.md From 46fb4ad5e8df378256e214da0b7e22634dce899e Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Wed, 23 Oct 2019 13:38:31 +0200 Subject: [PATCH 076/141] Exchange NonSerializable template engine with GStringTemplateEngine --- src/com/sap/piper/Utils.groovy | 4 ++-- vars/artifactSetVersion.groovy | 4 ++-- vars/batsExecuteTests.groovy | 4 ++-- vars/buildExecute.groovy | 2 +- vars/gaugeExecuteTests.groovy | 2 +- vars/karmaExecuteTests.groovy | 2 +- vars/mailSendNotification.groovy | 4 ++-- vars/newmanExecute.groovy | 4 ++-- vars/seleniumExecuteTests.groovy | 2 +- vars/slackSendNotification.groovy | 6 +++--- vars/sonarExecuteScan.groovy | 2 +- vars/uiVeri5ExecuteTests.groovy | 6 +++--- vars/whitesourceExecuteScan.groovy | 4 ++-- 13 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/com/sap/piper/Utils.groovy b/src/com/sap/piper/Utils.groovy index 11486b1b9..311620536 100644 --- a/src/com/sap/piper/Utils.groovy +++ b/src/com/sap/piper/Utils.groovy @@ -2,7 +2,7 @@ package com.sap.piper import com.cloudbees.groovy.cps.NonCPS import com.sap.piper.analytics.Telemetry -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import java.nio.charset.StandardCharsets import java.security.MessageDigest @@ -109,7 +109,7 @@ void pushToSWA(Map parameters, Map config) { @NonCPS static String fillTemplate(String templateText, Map binding) { - def engine = new SimpleTemplateEngine() + def engine = new GStringTemplateEngine() String result = engine.createTemplate(templateText).make(binding) return result } diff --git a/vars/artifactSetVersion.groovy b/vars/artifactSetVersion.groovy index 0cd234062..64668280a 100644 --- a/vars/artifactSetVersion.groovy +++ b/vars/artifactSetVersion.groovy @@ -7,7 +7,7 @@ import com.sap.piper.Utils import com.sap.piper.versioning.ArtifactVersioning import groovy.transform.Field -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine @Field String STEP_NAME = getClass().getName() @Field Map CONFIG_KEY_COMPATIBILITY = [gitSshKeyCredentialsId: 'gitCredentialsId'] @@ -144,7 +144,7 @@ void call(Map parameters = [:], Closure body = null) { newVersion = currentVersion } else { def binding = [version: currentVersion, timestamp: config.timestamp, commitId: config.gitCommitId] - newVersion = new SimpleTemplateEngine().createTemplate(config.versioningTemplate).make(binding).toString() + newVersion = new GStringTemplateEngine().createTemplate(config.versioningTemplate).make(binding).toString() } artifactVersioning.setVersion(newVersion) diff --git a/vars/batsExecuteTests.groovy b/vars/batsExecuteTests.groovy index 5f59122f3..b73014dce 100644 --- a/vars/batsExecuteTests.groovy +++ b/vars/batsExecuteTests.groovy @@ -5,7 +5,7 @@ import com.sap.piper.ConfigurationHelper import com.sap.piper.GitUtils import com.sap.piper.Utils import com.sap.piper.analytics.InfluxData -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() @@ -82,7 +82,7 @@ void call(Map parameters = [:]) { //resolve commonPipelineEnvironment references in envVars config.envVarList = [] config.envVars.each {e -> - def envValue = SimpleTemplateEngine.newInstance().createTemplate(e.getValue()).make(commonPipelineEnvironment: script.commonPipelineEnvironment).toString() + def envValue = GStringTemplateEngine.newInstance().createTemplate(e.getValue()).make(commonPipelineEnvironment: script.commonPipelineEnvironment).toString() config.envVarList.add("${e.getKey()}=${envValue}") } diff --git a/vars/buildExecute.groovy b/vars/buildExecute.groovy index b49555494..ab7d53509 100644 --- a/vars/buildExecute.groovy +++ b/vars/buildExecute.groovy @@ -3,7 +3,7 @@ import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field import static com.sap.piper.Prerequisites.checkScript diff --git a/vars/gaugeExecuteTests.groovy b/vars/gaugeExecuteTests.groovy index 1eacde6ae..ff66b900e 100644 --- a/vars/gaugeExecuteTests.groovy +++ b/vars/gaugeExecuteTests.groovy @@ -5,7 +5,7 @@ import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper import com.sap.piper.GitUtils import com.sap.piper.analytics.InfluxData -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() diff --git a/vars/karmaExecuteTests.groovy b/vars/karmaExecuteTests.groovy index 6202a7e11..1e67d7ec9 100644 --- a/vars/karmaExecuteTests.groovy +++ b/vars/karmaExecuteTests.groovy @@ -5,7 +5,7 @@ import com.sap.piper.GenerateDocumentation import com.sap.piper.GitUtils import com.sap.piper.Utils -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() diff --git a/vars/mailSendNotification.groovy b/vars/mailSendNotification.groovy index faeea1ba2..a139ed1fb 100644 --- a/vars/mailSendNotification.groovy +++ b/vars/mailSendNotification.groovy @@ -3,7 +3,7 @@ import static com.sap.piper.Prerequisites.checkScript import com.sap.piper.ConfigurationHelper import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() @@ -111,7 +111,7 @@ void call(Map parameters = [:]) { subject += ' is back to normal' } if(mailTemplate){ - def mailContent = SimpleTemplateEngine.newInstance().createTemplate(libraryResource(mailTemplate)).make([env: env, log: log]).toString() + def mailContent = GStringTemplateEngine.newInstance().createTemplate(libraryResource(mailTemplate)).make([env: env, log: log]).toString() def recipientList = '' if(config.notifyCulprits){ if (!config.gitUrl) { diff --git a/vars/newmanExecute.groovy b/vars/newmanExecute.groovy index f58a1eb53..1d81a2569 100644 --- a/vars/newmanExecute.groovy +++ b/vars/newmanExecute.groovy @@ -4,7 +4,7 @@ import com.sap.piper.ConfigurationHelper import com.sap.piper.GenerateDocumentation import com.sap.piper.GitUtils import com.sap.piper.Utils -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field @Field String STEP_NAME = getClass().getName() @@ -109,7 +109,7 @@ void call(Map parameters = [:]) { for(String collection : collectionList){ def collectionDisplayName = collection.toString().replace(File.separatorChar,(char)'_').tokenize('.').first() // resolve templates - def command = SimpleTemplateEngine.newInstance() + def command = GStringTemplateEngine.newInstance() .createTemplate(config.newmanRunCommand) .make([ config: config.plus([newmanCollection: collection]), diff --git a/vars/seleniumExecuteTests.groovy b/vars/seleniumExecuteTests.groovy index 8f5f3b197..312d389f5 100644 --- a/vars/seleniumExecuteTests.groovy +++ b/vars/seleniumExecuteTests.groovy @@ -6,7 +6,7 @@ import com.sap.piper.GitUtils import com.sap.piper.Utils import com.sap.piper.k8s.ContainerMap import groovy.transform.Field -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine @Field String STEP_NAME = getClass().getName() diff --git a/vars/slackSendNotification.groovy b/vars/slackSendNotification.groovy index 50153740c..fd09cc067 100644 --- a/vars/slackSendNotification.groovy +++ b/vars/slackSendNotification.groovy @@ -4,7 +4,7 @@ import com.sap.piper.ConfigurationHelper import com.sap.piper.GenerateDocumentation import com.sap.piper.Utils import groovy.transform.Field -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine @Field String STEP_NAME = getClass().getName() @@ -65,13 +65,13 @@ void call(Map parameters = [:]) { def buildStatus = script.currentBuild.result // resolve templates - config.color = SimpleTemplateEngine.newInstance().createTemplate(config.color).make([buildStatus: buildStatus]).toString() + config.color = GStringTemplateEngine.newInstance().createTemplate(config.color).make([buildStatus: buildStatus]).toString() if (!config?.message){ if (!buildStatus) { echo "[${STEP_NAME}] currentBuild.result is not set. Skipping Slack notification" return } - config.message = SimpleTemplateEngine.newInstance().createTemplate(config.defaultMessage).make([buildStatus: buildStatus, env: env]).toString() + config.message = GStringTemplateEngine.newInstance().createTemplate(config.defaultMessage).make([buildStatus: buildStatus, env: env]).toString() } Map options = [:] if(config.credentialsId) diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index e590d2097..5fcf1dac6 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -5,7 +5,7 @@ import com.sap.piper.Utils import static com.sap.piper.Prerequisites.checkScript import groovy.transform.Field -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import java.nio.charset.StandardCharsets diff --git a/vars/uiVeri5ExecuteTests.groovy b/vars/uiVeri5ExecuteTests.groovy index 17f7fe044..5071630b4 100644 --- a/vars/uiVeri5ExecuteTests.groovy +++ b/vars/uiVeri5ExecuteTests.groovy @@ -3,7 +3,7 @@ import com.sap.piper.GenerateDocumentation import com.sap.piper.GitUtils import com.sap.piper.Utils -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import groovy.transform.Field import static com.sap.piper.Prerequisites.checkScript @@ -107,8 +107,8 @@ void call(Map parameters = [:]) { ], config) config.stashContent = config.testRepository ? [GitUtils.handleTestRepository(this, config)] : utils.unstashAll(config.stashContent) - config.installCommand = SimpleTemplateEngine.newInstance().createTemplate(config.installCommand).make([config: config]).toString() - config.runCommand = SimpleTemplateEngine.newInstance().createTemplate(config.runCommand).make([config: config]).toString() + config.installCommand = GStringTemplateEngine.newInstance().createTemplate(config.installCommand).make([config: config]).toString() + config.runCommand = GStringTemplateEngine.newInstance().createTemplate(config.runCommand).make([config: config]).toString() config.dockerEnvVars.TARGET_SERVER_URL = config.dockerEnvVars.TARGET_SERVER_URL ?: config.testServerUrl seleniumExecuteTests( diff --git a/vars/whitesourceExecuteScan.groovy b/vars/whitesourceExecuteScan.groovy index 96027dcc7..24c35f007 100644 --- a/vars/whitesourceExecuteScan.groovy +++ b/vars/whitesourceExecuteScan.groovy @@ -9,7 +9,7 @@ import com.sap.piper.WhitesourceConfigurationHelper import com.sap.piper.mta.MtaMultiplexer import groovy.text.GStringTemplateEngine import groovy.transform.Field -import groovy.text.SimpleTemplateEngine +import groovy.text.GStringTemplateEngine import static com.sap.piper.Prerequisites.checkScript @@ -588,7 +588,7 @@ def getReportHtml(config, vulnerabilityList, numSevereVulns) { } } - return SimpleTemplateEngine.newInstance().createTemplate(libraryResource('com.sap.piper/templates/whitesourceVulnerabilities.html')).make( + return GStringTemplateEngine.newInstance().createTemplate(libraryResource('com.sap.piper/templates/whitesourceVulnerabilities.html')).make( [ now : now, reportTitle : config.whitesource.vulnerabilityReportTitle, From 3046f121c1201b66c3e53b77b31de9f90ac9b0bc Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 23 Oct 2019 14:31:49 +0200 Subject: [PATCH 077/141] Check against containerMap from config rather than params (#835) The parameter map is directly handed over from outside into the step via signature of the call method. The container map is defined as step parameters, not as parameter handed over (only) via the parameters map. With the current approach only the container map from the parameters is taken into account. In case the parametersMap is defined elsewhere it is not taken into account. --- vars/dockerExecuteOnKubernetes.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index e6dd5ed8f..885c7a14c 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -189,7 +189,7 @@ void call(Map parameters = [:], body) { stepParam1 : parameters?.script == null ], config) - if (!parameters.containerMap) { + if (!config.containerMap) { configHelper.withMandatoryProperty('dockerImage') config.containerName = 'container-exec' config.containerMap = [(config.get('dockerImage')): config.containerName] From 4e6104ebb48b552d2add58463f47fb443aab2c35 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Wed, 23 Oct 2019 14:56:29 +0200 Subject: [PATCH 078/141] Disable return statement maintainability check --- .codeclimate.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.codeclimate.yml b/.codeclimate.yml index 3fa6325e8..f6e7b6874 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,3 +1,7 @@ +version: "2" +checks: + return-statements: + enabled: false plugins: codenarc: enabled: true From 1f34511407b087d657a6a373fd889af8c77a7c42 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Thu, 24 Oct 2019 10:59:58 +0200 Subject: [PATCH 079/141] Provide golang based Piper library (#915) * Provide golang based Piper library This includes the main command and a sub command for config resolution --- .gitignore | 2 + cmd/getConfig.go | 115 +++++++++++++++++++++++ cmd/getConfig_test.go | 90 ++++++++++++++++++ cmd/piper.go | 58 ++++++++++++ main.go | 9 ++ pkg/config/stepmeta.go | 109 ++++++++++++++++++++-- pkg/config/stepmeta_test.go | 177 +++++++++++++++++++++++++++++------- 7 files changed, 519 insertions(+), 41 deletions(-) create mode 100644 cmd/getConfig.go create mode 100644 cmd/getConfig_test.go create mode 100644 cmd/piper.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore index 4a5fb35c2..8225066ba 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ documentation/docs-gen consumer-test/**/workspace *.code-workspace +piper +piper.exe diff --git a/cmd/getConfig.go b/cmd/getConfig.go new file mode 100644 index 000000000..ad8fc78f0 --- /dev/null +++ b/cmd/getConfig.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type configCommandOptions struct { + output string //output format, so far only JSON + parametersJSON string //parameters to be considered in JSON format + stepMetadata string //metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + stepName string + contextConfig bool + openFile func(s string) (io.ReadCloser, error) +} + +var configOptions configCommandOptions + +// ConfigCommand is the entry command for loading the configuration of a pipeline step +func ConfigCommand() *cobra.Command { + + configOptions.openFile = openPiperFile + var createConfigCmd = &cobra.Command{ + Use: "getConfig", + Short: "Loads the project 'Piper' configuration respecting defaults and parameters.", + RunE: func(cmd *cobra.Command, _ []string) error { + return generateConfig() + }, + } + + addConfigFlags(createConfigCmd) + return createConfigCmd +} + +func generateConfig() error { + + var myConfig config.Config + var stepConfig config.StepConfig + + var metadata config.StepData + metadataFile, err := configOptions.openFile(configOptions.stepMetadata) + if err != nil { + return errors.Wrap(err, "metadata: open failed") + } + + err = metadata.ReadPipelineStepData(metadataFile) + if err != nil { + return errors.Wrap(err, "metadata: read failed") + } + + customConfig, err := configOptions.openFile(generalConfig.customConfig) + if err != nil { + return errors.Wrap(err, "config: open failed") + } + + defaultConfig, paramFilter, err := defaultsAndFilters(&metadata) + if err != nil { + return errors.Wrap(err, "defaults: retrieving step defaults failed") + } + + for _, f := range generalConfig.defaultConfig { + fc, err := configOptions.openFile(f) + if err != nil { + return errors.Wrapf(err, "config: getting defaults failed: '%v'", f) + } + defaultConfig = append(defaultConfig, fc) + } + + var flags map[string]interface{} + + stepConfig, err = myConfig.GetStepConfig(flags, generalConfig.parametersJSON, customConfig, defaultConfig, paramFilter, generalConfig.stageName, configOptions.stepName) + if err != nil { + return errors.Wrap(err, "getting step config failed") + } + + //ToDo: Check for mandatory parameters + + myConfigJSON, _ := config.GetJSON(stepConfig.Config) + + fmt.Println(myConfigJSON) + + return nil +} + +func addConfigFlags(cmd *cobra.Command) { + + //ToDo: support more output options, like https://kubernetes.io/docs/reference/kubectl/overview/#formatting-output + cmd.Flags().StringVar(&configOptions.output, "output", "json", "Defines the output format") + + cmd.Flags().StringVar(&configOptions.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") + cmd.Flags().StringVar(&configOptions.stepMetadata, "stepMetadata", "", "Step metadata, passed as path to yaml") + cmd.Flags().StringVar(&configOptions.stepName, "stepName", "", "Name of the step for which configuration should be included") + cmd.Flags().BoolVar(&configOptions.contextConfig, "contextConfig", false, "Defines if step context configuration should be loaded instead of step config") + + cmd.MarkFlagRequired("stepMetadata") + cmd.MarkFlagRequired("stepName") + +} + +func defaultsAndFilters(metadata *config.StepData) ([]io.ReadCloser, config.StepFilters, error) { + if configOptions.contextConfig { + defaults, err := metadata.GetContextDefaults(configOptions.stepName) + if err != nil { + return nil, config.StepFilters{}, errors.Wrap(err, "metadata: getting context defaults failed") + } + return []io.ReadCloser{defaults}, metadata.GetContextParameterFilters(), nil + } + //ToDo: retrieve default values from metadata + return nil, metadata.GetParameterFilters(), nil +} diff --git a/cmd/getConfig_test.go b/cmd/getConfig_test.go new file mode 100644 index 000000000..e06b80499 --- /dev/null +++ b/cmd/getConfig_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +func openFileMock(name string) (io.ReadCloser, error) { + var r string + switch name { + case "TestAddCustomDefaults_default1": + r = "default1" + case "TestAddCustomDefaults_default2": + r = "default3" + default: + r = "" + } + return ioutil.NopCloser(strings.NewReader(r)), nil +} + +func TestConfigCommand(t *testing.T) { + cmd := ConfigCommand() + + gotReq := []string{} + gotOpt := []string{} + + cmd.Flags().VisitAll(func(pflag *flag.Flag) { + annotations, found := pflag.Annotations[cobra.BashCompOneRequiredFlag] + if found && annotations[0] == "true" { + gotReq = append(gotReq, pflag.Name) + } else { + gotOpt = append(gotOpt, pflag.Name) + } + }) + + t.Run("Required flags", func(t *testing.T) { + exp := []string{"stepMetadata", "stepName"} + assert.Equal(t, exp, gotReq, "required flags incorrect") + }) + + t.Run("Optional flags", func(t *testing.T) { + exp := []string{"contextConfig", "output", "parametersJSON"} + assert.Equal(t, exp, gotOpt, "optional flags incorrect") + }) + + t.Run("Run", func(t *testing.T) { + t.Run("Success case", func(t *testing.T) { + configOptions.openFile = openFileMock + err := cmd.RunE(cmd, []string{}) + assert.NoError(t, err, "error occured but none expected") + }) + }) +} + +func TestDefaultsAndFilters(t *testing.T) { + metadata := config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "paramOne", Scope: []string{"GENERAL", "STEPS", "STAGES", "PARAMETERS", "ENV"}}, + }, + }, + }, + } + + t.Run("Context config", func(t *testing.T) { + configOptions.contextConfig = true + defer func() { configOptions.contextConfig = false }() + defaults, filters, err := defaultsAndFilters(&metadata) + + assert.Equal(t, 1, len(defaults), "getting defaults failed") + assert.Equal(t, 0, len(filters.All), "wrong number of filter values") + assert.NoError(t, err, "error occured but none expected") + }) + + t.Run("Step config", func(t *testing.T) { + defaults, filters, err := defaultsAndFilters(&metadata) + assert.Equal(t, 0, len(defaults), "getting defaults failed") + assert.Equal(t, 1, len(filters.All), "wrong number of filter values") + assert.NoError(t, err, "error occured but none expected") + }) + +} diff --git a/cmd/piper.go b/cmd/piper.go new file mode 100644 index 000000000..6448728bf --- /dev/null +++ b/cmd/piper.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" +) + +type generalConfigOptions struct { + customConfig string + defaultConfig []string //ordered list of Piper default configurations. Can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + parametersJSON string + stageName string + stepConfigJSON string + stepMetadata string //metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + stepName string + verbose bool +} + +var rootCmd = &cobra.Command{ + Use: "piper", + Short: "Executes CI/CD steps from project 'Piper' ", + Long: ` +This project 'Piper' binary provides a CI/CD step libary. +It contains many steps which can be used within CI/CD systems as well as directly on e.g. a developer's machine. +`, + //ToDo: respect stageName to also come from parametersJSON -> first env.STAGE_NAME, second: parametersJSON, third: flag +} + +var generalConfig generalConfigOptions + +// Execute is the starting point of the piper command line tool +func Execute() { + + rootCmd.AddCommand(ConfigCommand()) + rootCmd.PersistentFlags().StringVar(&generalConfig.customConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") + rootCmd.PersistentFlags().StringSliceVar(&generalConfig.defaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") + rootCmd.PersistentFlags().StringVar(&generalConfig.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") + rootCmd.PersistentFlags().StringVar(&generalConfig.stageName, "stageName", os.Getenv("STAGE_NAME"), "Name of the stage for which configuration should be included") + rootCmd.PersistentFlags().StringVar(&generalConfig.stepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") + rootCmd.PersistentFlags().BoolVarP(&generalConfig.verbose, "verbose", "v", false, "verbose output") + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func openPiperFile(name string) (io.ReadCloser, error) { + //ToDo: support also https as source + if !strings.HasPrefix(name, "http") { + return os.Open(name) + } + return nil, fmt.Errorf("file location not yet supported for '%v'", name) +} diff --git a/main.go b/main.go new file mode 100644 index 000000000..46bb52a37 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/SAP/jenkins-library/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/pkg/config/stepmeta.go b/pkg/config/stepmeta.go index 22045b987..ecab79887 100644 --- a/pkg/config/stepmeta.go +++ b/pkg/config/stepmeta.go @@ -1,6 +1,8 @@ package config import ( + "bytes" + "fmt" "io" "io/ioutil" @@ -25,8 +27,8 @@ type StepMetadata struct { type StepSpec struct { Inputs StepInputs `json:"inputs"` // Outputs string `json:"description,omitempty"` - Containers []StepContainers `json:"containers,omitempty"` - Sidecars []StepSidecars `json:"sidecars,omitempty"` + Containers []Container `json:"containers,omitempty"` + Sidecars []Container `json:"sidecars,omitempty"` } // StepInputs defines the spec details for a step, like step inputs, containers, sidecars, ... @@ -66,14 +68,23 @@ type StepSecrets struct { // Name string `json:"name"` //} -// StepContainers defines the containers required for a step -type StepContainers struct { - Containers map[string]interface{} `json:"containers"` +// Container defines an execution container +type Container struct { + //ToDo: check dockerOptions, dockerVolumeBind, containerPortMappings, sidecarOptions, sidecarVolumeBind + Command []string `json:"command"` + EnvVars []EnvVar `json:"env"` + Image string `json:"image"` + ImagePullPolicy string `json:"imagePullPolicy"` + Name string `json:"name"` + ReadyCommand string `json:"readyCommand"` + Shell string `json:"shell"` + WorkingDir string `json:"workingDir"` } -// StepSidecars defines any sidears required for a step -type StepSidecars struct { - Sidecars map[string]interface{} `json:"sidecars"` +// EnvVar defines an environment variable +type EnvVar struct { + Name string `json:"name"` + Value string `json:"value"` } // StepFilters defines the filter parameters for the different sections @@ -135,5 +146,87 @@ func (m *StepData) GetContextParameterFilters() StepFilters { filters.Parameters = append(filters.Parameters, secret.Name) filters.Env = append(filters.Env, secret.Name) } + + containerFilters := []string{} + if len(m.Spec.Containers) > 0 { + containerFilters = append(containerFilters, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}...) + } + if len(m.Spec.Sidecars) > 0 { + //ToDo: support fallback for "dockerName" configuration property -> via aliasing? + containerFilters = append(containerFilters, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}...) + } + if len(containerFilters) > 0 { + filters.All = append(filters.All, containerFilters...) + filters.Steps = append(filters.Steps, containerFilters...) + filters.Stages = append(filters.Stages, containerFilters...) + filters.Parameters = append(filters.Parameters, containerFilters...) + } return filters } + +// GetContextDefaults retrieves context defaults like container image, name, env vars, ... +// It only supports scenarios with one container and optionally one sidecar +func (m *StepData) GetContextDefaults(stepName string) (io.ReadCloser, error) { + + p := map[string]interface{}{} + + //ToDo error handling empty Containers/Sidecars + //ToDo handle empty Command + + if len(m.Spec.Containers) > 0 { + if len(m.Spec.Containers[0].Command) > 0 { + p["containerCommand"] = m.Spec.Containers[0].Command[0] + } + p["containerName"] = m.Spec.Containers[0].Name + p["containerShell"] = m.Spec.Containers[0].Shell + p["dockerEnvVars"] = envVarsAsStringSlice(m.Spec.Containers[0].EnvVars) + p["dockerImage"] = m.Spec.Containers[0].Image + p["dockerName"] = m.Spec.Containers[0].Name + p["dockerPullImage"] = m.Spec.Containers[0].ImagePullPolicy != "Never" + p["dockerWorkspace"] = m.Spec.Containers[0].WorkingDir + + // Ready command not relevant for main runtime container so far + //p[] = m.Spec.Containers[0].ReadyCommand + } + + if len(m.Spec.Sidecars) > 0 { + if len(m.Spec.Sidecars[0].Command) > 0 { + p["sidecarCommand"] = m.Spec.Sidecars[0].Command[0] + } + p["sidecarEnvVars"] = envVarsAsStringSlice(m.Spec.Sidecars[0].EnvVars) + p["sidecarImage"] = m.Spec.Sidecars[0].Image + p["sidecarName"] = m.Spec.Sidecars[0].Name + p["sidecarPullImage"] = m.Spec.Sidecars[0].ImagePullPolicy != "Never" + p["sidecarReadyCommand"] = m.Spec.Sidecars[0].ReadyCommand + p["sidecarWorkspace"] = m.Spec.Sidecars[0].WorkingDir + } + + // not filled for now since this is not relevant in Kubernetes case + //p["dockerOptions"] = m.Spec.Containers[0]. + //p["dockerVolumeBind"] = m.Spec.Containers[0]. + //p["containerPortMappings"] = m.Spec.Sidecars[0]. + //p["sidecarOptions"] = m.Spec.Sidecars[0]. + //p["sidecarVolumeBind"] = m.Spec.Sidecars[0]. + + c := Config{ + Steps: map[string]map[string]interface{}{ + stepName: p, + }, + } + + JSON, err := yaml.Marshal(c) + if err != nil { + return nil, errors.Wrap(err, "failed to create context defaults") + } + + r := ioutil.NopCloser(bytes.NewReader(JSON)) + return r, nil +} + +func envVarsAsStringSlice(envVars []EnvVar) []string { + e := []string{} + for _, v := range envVars { + e = append(e, fmt.Sprintf("%v=%v", v.Name, v.Value)) + } + return e +} diff --git a/pkg/config/stepmeta_test.go b/pkg/config/stepmeta_test.go index 307f57f6a..5d3a73c50 100644 --- a/pkg/config/stepmeta_test.go +++ b/pkg/config/stepmeta_test.go @@ -2,9 +2,12 @@ package config import ( "fmt" + "io" "io/ioutil" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestReadPipelineStepData(t *testing.T) { @@ -227,40 +230,148 @@ func TestGetContextParameterFilters(t *testing.T) { }, } - filters := metadata1.GetContextParameterFilters() + metadata2 := StepData{ + Spec: StepSpec{ + Containers: []Container{ + {Name: "testcontainer"}, + }, + }, + } + + metadata3 := StepData{ + Spec: StepSpec{ + Sidecars: []Container{ + {Name: "testsidecar"}, + }, + }, + } t.Run("Secrets", func(t *testing.T) { - for _, s := range metadata1.Spec.Inputs.Secrets { - t.Run("All", func(t *testing.T) { - if !sliceContains(filters.All, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - t.Run("General", func(t *testing.T) { - if !sliceContains(filters.General, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - t.Run("Step", func(t *testing.T) { - if !sliceContains(filters.Steps, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - t.Run("Stages", func(t *testing.T) { - if !sliceContains(filters.Steps, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - t.Run("Parameters", func(t *testing.T) { - if !sliceContains(filters.Parameters, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - t.Run("Env", func(t *testing.T) { - if !sliceContains(filters.Env, s.Name) { - t.Errorf("Creation of context filter failed, expected: %v to be contained", s.Name) - } - }) - } + filters := metadata1.GetContextParameterFilters() + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.All, "incorrect filter All") + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.General, "incorrect filter General") + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.Steps, "incorrect filter Steps") + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.Stages, "incorrect filter Stages") + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.Parameters, "incorrect filter Parameters") + assert.Equal(t, []string{"testSecret1", "testSecret2"}, filters.Env, "incorrect filter Env") + }) + + t.Run("Containers", func(t *testing.T) { + filters := metadata2.GetContextParameterFilters() + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.All, "incorrect filter All") + assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.General, "incorrect filter General") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Steps, "incorrect filter Steps") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Stages, "incorrect filter Stages") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Parameters, "incorrect filter Parameters") + assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Env, "incorrect filter Env") + }) + + t.Run("Sidecars", func(t *testing.T) { + filters := metadata3.GetContextParameterFilters() + assert.Equal(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.All, "incorrect filter All") + assert.NotEqual(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.General, "incorrect filter General") + assert.Equal(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.Steps, "incorrect filter Steps") + assert.Equal(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.Stages, "incorrect filter Stages") + assert.Equal(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.Parameters, "incorrect filter Parameters") + assert.NotEqual(t, []string{"containerName", "containerPortMappings", "dockerName", "sidecarEnvVars", "sidecarImage", "sidecarName", "sidecarOptions", "sidecarPullImage", "sidecarReadyCommand", "sidecarVolumeBind", "sidecarWorkspace"}, filters.Env, "incorrect filter Env") + }) +} + +func TestGetContextDefaults(t *testing.T) { + + t.Run("Positive case", func(t *testing.T) { + metadata := StepData{ + Spec: StepSpec{ + Containers: []Container{ + { + Command: []string{"test/command"}, + EnvVars: []EnvVar{ + {Name: "env1", Value: "val1"}, + {Name: "env2", Value: "val2"}, + }, + Name: "testcontainer", + Image: "testImage:tag", + Shell: "/bin/bash", + WorkingDir: "/test/dir", + }, + }, + Sidecars: []Container{ + { + Command: []string{"/sidecar/command"}, + EnvVars: []EnvVar{ + {Name: "env3", Value: "val3"}, + {Name: "env4", Value: "val4"}, + }, + Name: "testsidecar", + Image: "testSidecarImage:tag", + ImagePullPolicy: "Never", + ReadyCommand: "/sidecar/command", + WorkingDir: "/sidecar/dir", + }, + }, + }, + } + + cd, err := metadata.GetContextDefaults("testStep") + + t.Run("No error", func(t *testing.T) { + if err != nil { + t.Errorf("No error expected but got error '%v'", err) + } + }) + + var d PipelineDefaults + d.ReadPipelineDefaults([]io.ReadCloser{cd}) + + assert.Equal(t, "test/command", d.Defaults[0].Steps["testStep"]["containerCommand"], "containerCommand default not available") + assert.Equal(t, "testcontainer", d.Defaults[0].Steps["testStep"]["containerName"], "containerName default not available") + assert.Equal(t, "/bin/bash", d.Defaults[0].Steps["testStep"]["containerShell"], "containerShell default not available") + assert.Equal(t, []interface{}{"env1=val1", "env2=val2"}, d.Defaults[0].Steps["testStep"]["dockerEnvVars"], "dockerEnvVars default not available") + assert.Equal(t, "testImage:tag", d.Defaults[0].Steps["testStep"]["dockerImage"], "dockerImage default not available") + assert.Equal(t, "testcontainer", d.Defaults[0].Steps["testStep"]["dockerName"], "dockerName default not available") + assert.Equal(t, true, d.Defaults[0].Steps["testStep"]["dockerPullImage"], "dockerPullImage default not available") + assert.Equal(t, "/test/dir", d.Defaults[0].Steps["testStep"]["dockerWorkspace"], "dockerWorkspace default not available") + + assert.Equal(t, "/sidecar/command", d.Defaults[0].Steps["testStep"]["sidecarCommand"], "sidecarCommand default not available") + assert.Equal(t, []interface{}{"env3=val3", "env4=val4"}, d.Defaults[0].Steps["testStep"]["sidecarEnvVars"], "sidecarEnvVars default not available") + assert.Equal(t, "testSidecarImage:tag", d.Defaults[0].Steps["testStep"]["sidecarImage"], "sidecarImage default not available") + assert.Equal(t, "testsidecar", d.Defaults[0].Steps["testStep"]["sidecarName"], "sidecarName default not available") + assert.Equal(t, false, d.Defaults[0].Steps["testStep"]["sidecarPullImage"], "sidecarPullImage default not available") + assert.Equal(t, "/sidecar/command", d.Defaults[0].Steps["testStep"]["sidecarReadyCommand"], "sidecarReadyCommand default not available") + assert.Equal(t, "/sidecar/dir", d.Defaults[0].Steps["testStep"]["sidecarWorkspace"], "sidecarWorkspace default not available") + }) + + t.Run("Negative case", func(t *testing.T) { + metadataErr := []StepData{ + StepData{}, + StepData{ + Spec: StepSpec{}, + }, + StepData{ + Spec: StepSpec{ + Containers: []Container{}, + Sidecars: []Container{}, + }, + }, + } + + t.Run("No containers/sidecars", func(t *testing.T) { + cd, _ := metadataErr[0].GetContextDefaults("testStep") + + var d PipelineDefaults + d.ReadPipelineDefaults([]io.ReadCloser{cd}) + + //no assert since we just want to make sure that no panic occurs + }) + + t.Run("No command", func(t *testing.T) { + cd, _ := metadataErr[1].GetContextDefaults("testStep") + + var d PipelineDefaults + d.ReadPipelineDefaults([]io.ReadCloser{cd}) + + //no assert since we just want to make sure that no panic occurs + }) + }) } From 77e9b04e69460bfd21c17ea3b82c6958b3320f1d Mon Sep 17 00:00:00 2001 From: Christoph Szymanski Date: Thu, 24 Oct 2019 16:35:11 +0200 Subject: [PATCH 080/141] Document MTA (Java) Deployment Limitation (#854) * Update neoDeploy.md * Clarification + hint to trial limitation * Mention Java explicitly * Fix typo * DeployMode MTA --> mta according to other parts of the docu and according to the source code, the deploy mode is in lower case. --- documentation/docs/steps/neoDeploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/steps/neoDeploy.md b/documentation/docs/steps/neoDeploy.md index 70f8a4cb6..ad9e88339 100644 --- a/documentation/docs/steps/neoDeploy.md +++ b/documentation/docs/steps/neoDeploy.md @@ -4,7 +4,7 @@ ## Prerequisites -* **SAP CP account** - the account to where the application is deployed. +* **SAP CP account** - the account to where the application is deployed. To deploy MTA (`deployMode: mta`) an over existing _Java_ application, free _Java Quota_ of at least 1 is required, which means that this will not work on trial accounts. * **SAP CP user for deployment** - a user with deployment permissions in the given account. * **Jenkins credentials for deployment** - must be configured in Jenkins credentials with a dedicated Id. From d053653a93b729c949adbcd887f08efec025fdc6 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 25 Oct 2019 14:58:59 +0200 Subject: [PATCH 081/141] Add golang implementation for karma tests (#919) * Provide golang based karma step --- .editorconfig | 2 +- cmd/getConfig_test.go | 4 +- cmd/interfaces.go | 6 + cmd/karmaExecuteTests.go | 35 ++ cmd/karmaExecuteTests_generated.go | 87 +++++ cmd/karmaExecuteTests_generated_test.go | 16 + cmd/karmaExecuteTests_test.go | 65 ++++ cmd/piper.go | 55 ++- cmd/piper_test.go | 110 ++++++ pkg/command/command.go | 134 ++++++++ pkg/command/command_test.go | 182 ++++++++++ pkg/config/stepmeta_test.go | 1 - pkg/generator/step-metadata.go | 312 ++++++++++++++++++ pkg/generator/step-metadata_test.go | 227 +++++++++++++ .../step_code_generated.golden | 76 +++++ .../test_code_generated.golden | 16 + resources/metadata/karma.yaml | 67 ++++ 17 files changed, 1388 insertions(+), 7 deletions(-) create mode 100644 cmd/interfaces.go create mode 100644 cmd/karmaExecuteTests.go create mode 100644 cmd/karmaExecuteTests_generated.go create mode 100644 cmd/karmaExecuteTests_generated_test.go create mode 100644 cmd/karmaExecuteTests_test.go create mode 100644 cmd/piper_test.go create mode 100644 pkg/command/command.go create mode 100644 pkg/command/command_test.go create mode 100644 pkg/generator/step-metadata.go create mode 100644 pkg/generator/step-metadata_test.go create mode 100644 pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden create mode 100644 pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden create mode 100644 resources/metadata/karma.yaml diff --git a/.editorconfig b/.editorconfig index 37225c59b..0aa9c2474 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,6 +22,6 @@ indent_size = none [cfg/id_rsa.enc] indent_style = none indent_size = none -[{go.mod,go.sum,*.go}] +[{go.mod,go.sum,*.go,*.golden}] indent_style = tab indent_size = 8 diff --git a/cmd/getConfig_test.go b/cmd/getConfig_test.go index e06b80499..4975b37ef 100644 --- a/cmd/getConfig_test.go +++ b/cmd/getConfig_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" ) -func openFileMock(name string) (io.ReadCloser, error) { +func configOpenFileMock(name string) (io.ReadCloser, error) { var r string switch name { case "TestAddCustomDefaults_default1": @@ -52,7 +52,7 @@ func TestConfigCommand(t *testing.T) { t.Run("Run", func(t *testing.T) { t.Run("Success case", func(t *testing.T) { - configOptions.openFile = openFileMock + configOptions.openFile = configOpenFileMock err := cmd.RunE(cmd, []string{}) assert.NoError(t, err, "error occured but none expected") }) diff --git a/cmd/interfaces.go b/cmd/interfaces.go new file mode 100644 index 000000000..fb0515ab8 --- /dev/null +++ b/cmd/interfaces.go @@ -0,0 +1,6 @@ +package cmd + +type execRunner interface { + RunExecutable(e string, p ...string) error + Dir(d string) +} diff --git a/cmd/karmaExecuteTests.go b/cmd/karmaExecuteTests.go new file mode 100644 index 000000000..16680b8ac --- /dev/null +++ b/cmd/karmaExecuteTests.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "strings" + + "github.com/SAP/jenkins-library/pkg/command" + "github.com/pkg/errors" +) + +func karmaExecuteTests(myKarmaExecuteTestsOptions karmaExecuteTestsOptions) error { + c := command.Command{} + return runKarma(myKarmaExecuteTestsOptions, &c) +} + +func runKarma(myKarmaExecuteTestsOptions karmaExecuteTestsOptions, command execRunner) error { + installCommandTokens := tokenize(myKarmaExecuteTestsOptions.InstallCommand) + command.Dir(myKarmaExecuteTestsOptions.ModulePath) + err := command.RunExecutable(installCommandTokens[0], installCommandTokens[1:]...) + if err != nil { + return errors.Wrapf(err, "failed to execute install command '%v'", myKarmaExecuteTestsOptions.InstallCommand) + } + + runCommandTokens := tokenize(myKarmaExecuteTestsOptions.RunCommand) + command.Dir(myKarmaExecuteTestsOptions.ModulePath) + err = command.RunExecutable(runCommandTokens[0], runCommandTokens[1:]...) + if err != nil { + return errors.Wrapf(err, "failed to execute run command '%v'", myKarmaExecuteTestsOptions.RunCommand) + } + + return nil +} + +func tokenize(command string) []string { + return strings.Split(command, " ") +} diff --git a/cmd/karmaExecuteTests_generated.go b/cmd/karmaExecuteTests_generated.go new file mode 100644 index 000000000..5b331d126 --- /dev/null +++ b/cmd/karmaExecuteTests_generated.go @@ -0,0 +1,87 @@ +package cmd + +import ( + //"os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" +) + +type karmaExecuteTestsOptions struct { + InstallCommand string `json:"installCommand,omitempty"` + ModulePath string `json:"modulePath,omitempty"` + RunCommand string `json:"runCommand,omitempty"` +} + +var myKarmaExecuteTestsOptions karmaExecuteTestsOptions +var karmaExecuteTestsStepConfigJSON string + +// KarmaExecuteTestsCommand Executes the Karma test runner +func KarmaExecuteTestsCommand() *cobra.Command { + metadata := karmaExecuteTestsMetadata() + var createKarmaExecuteTestsCmd = &cobra.Command{ + Use: "karmaExecuteTests", + Short: "Executes the Karma test runner", + Long: `In this step the ([Karma test runner](http://karma-runner.github.io)) is executed. + +The step is using the ` + "`" + `seleniumExecuteTest` + "`" + ` step to spin up two containers in a Docker network: + +* a Selenium/Chrome container (` + "`" + `selenium/standalone-chrome` + "`" + `) +* a NodeJS container (` + "`" + `node:8-stretch` + "`" + `) + +In the Docker network, the containers can be referenced by the values provided in ` + "`" + `dockerName` + "`" + ` and ` + "`" + `sidecarName` + "`" + `, the default values are ` + "`" + `karma` + "`" + ` and ` + "`" + `selenium` + "`" + `. These values must be used in the ` + "`" + `hostname` + "`" + ` properties of the test configuration ([Karma](https://karma-runner.github.io/1.0/config/configuration-file.html) and [WebDriver](https://github.com/karma-runner/karma-webdriver-launcher#usage)). + +!!! note + In a Kubernetes environment, the containers both need to be referenced with ` + "`" + `localhost` + "`" + `.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return PrepareConfig(cmd, &metadata, "karmaExecuteTests", &myKarmaExecuteTestsOptions, openPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return karmaExecuteTests(myKarmaExecuteTestsOptions) + }, + } + + addKarmaExecuteTestsFlags(createKarmaExecuteTestsCmd) + return createKarmaExecuteTestsCmd +} + +func addKarmaExecuteTestsFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&myKarmaExecuteTestsOptions.InstallCommand, "installCommand", "npm install --quiet", "The command that is executed to install the test tool.") + cmd.Flags().StringVar(&myKarmaExecuteTestsOptions.ModulePath, "modulePath", ".", "Define the path of the module to execute tests on.") + cmd.Flags().StringVar(&myKarmaExecuteTestsOptions.RunCommand, "runCommand", "npm run karma", "The command that is executed to start the tests.") + + cmd.MarkFlagRequired("installCommand") + cmd.MarkFlagRequired("modulePath") + cmd.MarkFlagRequired("runCommand") +} + +// retrieve step metadata +func karmaExecuteTestsMetadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + { + Name: "installCommand", + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "modulePath", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "runCommand", + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + }, + }, + }, + } + return theMetaData +} diff --git a/cmd/karmaExecuteTests_generated_test.go b/cmd/karmaExecuteTests_generated_test.go new file mode 100644 index 000000000..6394ca1de --- /dev/null +++ b/cmd/karmaExecuteTests_generated_test.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKarmaExecuteTestsCommand(t *testing.T) { + + testCmd := KarmaExecuteTestsCommand() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "karmaExecuteTests", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/karmaExecuteTests_test.go b/cmd/karmaExecuteTests_test.go new file mode 100644 index 000000000..578de4721 --- /dev/null +++ b/cmd/karmaExecuteTests_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockRunner struct { + dir []string + calls []execCall +} + +type execCall struct { + exec string + params []string +} + +func (m *mockRunner) Dir(d string) { + m.dir = append(m.dir, d) +} + +func (m *mockRunner) RunExecutable(e string, p ...string) error { + if e == "fail" { + return fmt.Errorf("error case") + } + exec := execCall{exec: e, params: p} + m.calls = append(m.calls, exec) + return nil +} + +func TestRunKarma(t *testing.T) { + t.Run("success case", func(t *testing.T) { + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "npm run test"} + + e := mockRunner{} + err := runKarma(opts, &e) + + assert.NoError(t, err, "error occured but no error expected") + + assert.Equal(t, e.dir[0], "./test", "install command dir incorrect") + assert.Equal(t, e.calls[0], execCall{exec: "npm", params: []string{"install", "test"}}, "install command/params incorrect") + + assert.Equal(t, e.dir[1], "./test", "run command dir incorrect") + assert.Equal(t, e.calls[1], execCall{exec: "npm", params: []string{"run", "test"}}, "run command/params incorrect") + + }) + + t.Run("error case install command", func(t *testing.T) { + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "fail install test", RunCommand: "npm run test"} + + e := mockRunner{} + err := runKarma(opts, &e) + assert.Error(t, err, "error expected but none occcured") + }) + + t.Run("error case run command", func(t *testing.T) { + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "fail run test"} + + e := mockRunner{} + err := runKarma(opts, &e) + assert.Error(t, err, "error expected but none occcured") + }) +} diff --git a/cmd/piper.go b/cmd/piper.go index 6448728bf..328655ec2 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -1,11 +1,14 @@ package cmd import ( + "encoding/json" "fmt" "io" "os" "strings" + "github.com/SAP/jenkins-library/pkg/config" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -36,6 +39,16 @@ var generalConfig generalConfigOptions func Execute() { rootCmd.AddCommand(ConfigCommand()) + + addRootFlags(rootCmd) + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func addRootFlags(rootCmd *cobra.Command) { + rootCmd.PersistentFlags().StringVar(&generalConfig.customConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") rootCmd.PersistentFlags().StringSliceVar(&generalConfig.defaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") rootCmd.PersistentFlags().StringVar(&generalConfig.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") @@ -43,10 +56,46 @@ func Execute() { rootCmd.PersistentFlags().StringVar(&generalConfig.stepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") rootCmd.PersistentFlags().BoolVarP(&generalConfig.verbose, "verbose", "v", false, "verbose output") - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) +} + +// PrepareConfig reads step configuration from various sources and merges it (defaults, config file, flags, ...) +func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName string, options interface{}, openFile func(s string) (io.ReadCloser, error)) error { + + filters := metadata.GetParameterFilters() + + flagValues := config.AvailableFlagValues(cmd, &filters) + + var myConfig config.Config + var stepConfig config.StepConfig + + if len(generalConfig.stepConfigJSON) != 0 { + // ignore config & defaults in favor of passed stepConfigJSON + stepConfig = config.GetStepConfigWithJSON(flagValues, generalConfig.stepConfigJSON, filters) + } else { + // use config & defaults + + //accept that config file and defaults cannot be loaded since both are not mandatory here + customConfig, _ := openFile(generalConfig.customConfig) + var defaultConfig []io.ReadCloser + for _, f := range generalConfig.defaultConfig { + //ToDo: support also https as source + fc, _ := openFile(f) + defaultConfig = append(defaultConfig, fc) + } + + var err error + stepConfig, err = myConfig.GetStepConfig(flagValues, generalConfig.parametersJSON, customConfig, defaultConfig, filters, generalConfig.stageName, stepName) + if err != nil { + return errors.Wrap(err, "retrieving step configuration failed") + } } + + confJSON, _ := json.Marshal(stepConfig.Config) + json.Unmarshal(confJSON, &options) + + config.MarkFlagsWithValue(cmd, stepConfig) + + return nil } func openPiperFile(name string) (io.ReadCloser, error) { diff --git a/cmd/piper_test.go b/cmd/piper_test.go new file mode 100644 index 000000000..f7eaed902 --- /dev/null +++ b/cmd/piper_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +type stepOptions struct { + TestParam string `json:"testParam,omitempty"` +} + +func openFileMock(name string) (io.ReadCloser, error) { + var r string + switch name { + case "testDefaults.yml": + r = "general:\n testParam: testValue" + case "testDefaultsInvalid.yml": + r = "invalid yaml" + default: + r = "" + } + return ioutil.NopCloser(strings.NewReader(r)), nil +} + +func TestAddRootFlags(t *testing.T) { + var testRootCmd = &cobra.Command{Use: "test", Short: "This is just a test"} + addRootFlags(testRootCmd) + + assert.NotNil(t, testRootCmd.Flag("customConfig"), "expected flag not available") + assert.NotNil(t, testRootCmd.Flag("defaultConfig"), "expected flag not available") + assert.NotNil(t, testRootCmd.Flag("parametersJSON"), "expected flag not available") + assert.NotNil(t, testRootCmd.Flag("stageName"), "expected flag not available") + assert.NotNil(t, testRootCmd.Flag("stepConfigJSON"), "expected flag not available") + assert.NotNil(t, testRootCmd.Flag("verbose"), "expected flag not available") + +} + +func TestPrepareConfig(t *testing.T) { + defaultsBak := generalConfig.defaultConfig + generalConfig.defaultConfig = []string{"testDefaults.yml"} + defer func() { generalConfig.defaultConfig = defaultsBak }() + + t.Run("using stepConfigJSON", func(t *testing.T) { + stepConfigJSONBak := generalConfig.stepConfigJSON + generalConfig.stepConfigJSON = `{"testParam": "testValueJSON"}` + defer func() { generalConfig.stepConfigJSON = stepConfigJSONBak }() + testOptions := stepOptions{} + var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} + testCmd.Flags().StringVar(&testOptions.TestParam, "testParam", "", "test usage") + metadata := config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "testParam", Scope: []string{"GENERAL"}}, + }, + }, + }, + } + + PrepareConfig(testCmd, &metadata, "testStep", &testOptions, openFileMock) + assert.Equal(t, "testValueJSON", testOptions.TestParam, "wrong value retrieved from config") + }) + + t.Run("using config files", func(t *testing.T) { + t.Run("success case", func(t *testing.T) { + testOptions := stepOptions{} + var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} + testCmd.Flags().StringVar(&testOptions.TestParam, "testParam", "", "test usage") + metadata := config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "testParam", Scope: []string{"GENERAL"}}, + }, + }, + }, + } + + err := PrepareConfig(testCmd, &metadata, "testStep", &testOptions, openFileMock) + assert.NoError(t, err, "no error expected but error occured") + + //assert config + assert.Equal(t, "testValue", testOptions.TestParam, "wrong value retrieved from config") + + //assert that flag has been marked as changed + testCmd.Flags().VisitAll(func(pflag *flag.Flag) { + if pflag.Name == "testParam" { + assert.True(t, pflag.Changed, "flag should be marked as changed") + } + }) + }) + + t.Run("error case", func(t *testing.T) { + generalConfig.defaultConfig = []string{"testDefaultsInvalid.yml"} + testOptions := stepOptions{} + var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} + metadata := config.StepData{} + + err := PrepareConfig(testCmd, &metadata, "testStep", &testOptions, openFileMock) + assert.Error(t, err, "error expected but none occured") + }) + }) +} diff --git a/pkg/command/command.go b/pkg/command/command.go new file mode 100644 index 000000000..0db3378c8 --- /dev/null +++ b/pkg/command/command.go @@ -0,0 +1,134 @@ +package command + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "sync" + + "github.com/pkg/errors" +) + +// Command defines the information required for executing a call to any executable +type Command struct { + dir string + Stdout io.Writer + Stderr io.Writer +} + +// Dir sets the working directory for the execution +func (c *Command) Dir(d string) { + c.dir = d +} + +// ExecCommand defines how to execute os commands +var ExecCommand = exec.Command + +// RunShell runs the specified command on the shell +func (c *Command) RunShell(shell, script string) error { + + _out, _err := prepareOut(c.Stdout, c.Stderr) + + cmd := ExecCommand(shell) + + cmd.Dir = c.dir + in := bytes.Buffer{} + in.Write([]byte(script)) + cmd.Stdin = &in + + if err := runCmd(cmd, _out, _err); err != nil { + return errors.Wrapf(err, "running shell script failed with %v", shell) + } + return nil +} + +// RunExecutable runs the specified executable with parameters +func (c *Command) RunExecutable(executable string, params ...string) error { + + _out, _err := prepareOut(c.Stdout, c.Stderr) + + cmd := ExecCommand(executable, params...) + + if len(c.dir) > 0 { + cmd.Dir = c.dir + } + + if err := runCmd(cmd, _out, _err); err != nil { + return errors.Wrapf(err, "running command '%v' failed", executable) + } + return nil +} + +func runCmd(cmd *exec.Cmd, _out, _err io.Writer) error { + + stdout, stderr, err := cmdPipes(cmd) + + if err != nil { + return errors.Wrap(err, "getting commmand pipes failed") + } + + err = cmd.Start() + if err != nil { + return errors.Wrap(err, "starting command failed") + } + + var wg sync.WaitGroup + wg.Add(2) + + var errStdout, errStderr error + + go func() { + _, errStdout = io.Copy(_out, stdout) + wg.Done() + }() + + go func() { + _, errStderr = io.Copy(_err, stderr) + wg.Done() + }() + + wg.Wait() + + err = cmd.Wait() + + if err != nil { + return errors.Wrap(err, "cmd.Run() failed") + } + + if errStdout != nil || errStderr != nil { + return fmt.Errorf("failed to capture stdout/stderr: '%v'/'%v'", errStdout, errStderr) + } + + return nil +} + +func prepareOut(stdout, stderr io.Writer) (io.Writer, io.Writer) { + + //ToDo: check use of multiwriter instead to always write into os.Stdout and os.Stdin? + //stdout := io.MultiWriter(os.Stdout, &stdoutBuf) + //stderr := io.MultiWriter(os.Stderr, &stderrBuf) + + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdout, stderr +} + +func cmdPipes(cmd *exec.Cmd) (io.ReadCloser, io.ReadCloser, error) { + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting Stdout pipe failed") + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, nil, errors.Wrap(err, "getting Stderr pipe failed") + } + return stdout, stderr, nil +} diff --git a/pkg/command/command_test.go b/pkg/command/command_test.go new file mode 100644 index 000000000..beb231531 --- /dev/null +++ b/pkg/command/command_test.go @@ -0,0 +1,182 @@ +package command + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "testing" +) + +//based on https://golang.org/src/os/exec/exec_test.go +func helperCommand(command string, s ...string) (cmd *exec.Cmd) { + cs := []string{"-test.run=TestHelperProcess", "--", command} + cs = append(cs, s...) + cmd = exec.Command(os.Args[0], cs...) + cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} + return cmd +} + +func TestShellRun(t *testing.T) { + + t.Run("test shell", func(t *testing.T) { + ExecCommand = helperCommand + defer func() { ExecCommand = exec.Command }() + o := new(bytes.Buffer) + e := new(bytes.Buffer) + + s := Command{Stdout: o, Stderr: e} + s.RunShell("/bin/bash", "myScript") + + t.Run("success case", func(t *testing.T) { + t.Run("stdin-stdout", func(t *testing.T) { + expectedOut := "Stdout: command /bin/bash - Stdin: myScript\n" + if oStr := o.String(); oStr != expectedOut { + t.Errorf("expected: %v got: %v", expectedOut, oStr) + } + }) + t.Run("stderr", func(t *testing.T) { + expectedErr := "Stderr: command /bin/bash\n" + if eStr := e.String(); eStr != expectedErr { + t.Errorf("expected: %v got: %v", expectedErr, eStr) + } + }) + }) + }) +} + +func TestExecutableRun(t *testing.T) { + + t.Run("test shell", func(t *testing.T) { + ExecCommand = helperCommand + defer func() { ExecCommand = exec.Command }() + o := new(bytes.Buffer) + e := new(bytes.Buffer) + + ex := Command{Stdout: o, Stderr: e} + ex.RunExecutable("echo", []string{"foo bar", "baz"}...) + + t.Run("success case", func(t *testing.T) { + t.Run("stdin", func(t *testing.T) { + expectedOut := "foo bar baz\n" + if oStr := o.String(); oStr != expectedOut { + t.Errorf("expected: %v got: %v", expectedOut, oStr) + } + }) + t.Run("stderr", func(t *testing.T) { + expectedErr := "Stderr: command echo\n" + if eStr := e.String(); eStr != expectedErr { + t.Errorf("expected: %v got: %v", expectedErr, eStr) + } + }) + }) + }) +} + +func TestPrepareOut(t *testing.T) { + + t.Run("os", func(t *testing.T) { + s := Command{} + _out, _err := prepareOut(s.Stdout, s.Stderr) + + if _out != os.Stdout { + t.Errorf("expected out to be os.Stdout") + } + + if _err != os.Stderr { + t.Errorf("expected err to be os.Stderr") + } + }) + + t.Run("custom", func(t *testing.T) { + o := bytes.NewBufferString("") + e := bytes.NewBufferString("") + s := Command{Stdout: o, Stderr: e} + _out, _err := prepareOut(s.Stdout, s.Stderr) + + expectOut := "Test out" + expectErr := "Test err" + _out.Write([]byte(expectOut)) + _err.Write([]byte(expectErr)) + + t.Run("out", func(t *testing.T) { + if o.String() != expectOut { + t.Errorf("expected: %v got: %v", expectOut, o.String()) + } + }) + t.Run("err", func(t *testing.T) { + if e.String() != expectErr { + t.Errorf("expected: %v got: %v", expectErr, e.String()) + } + }) + }) +} + +func TestCmdPipes(t *testing.T) { + //cmd := helperCommand(t, "echo", "foo bar", "baz") + cmd := helperCommand("echo", "foo bar", "baz") + defer func() { ExecCommand = exec.Command }() + + t.Run("success case", func(t *testing.T) { + o, e, err := cmdPipes(cmd) + t.Run("no error", func(t *testing.T) { + if err != nil { + t.Errorf("error occured but no error expected") + } + }) + + t.Run("out pipe", func(t *testing.T) { + if o == nil { + t.Errorf("no pipe received") + } + }) + + t.Run("err pipe", func(t *testing.T) { + if e == nil { + t.Errorf("no pipe received") + } + }) + }) +} + +//based on https://golang.org/src/os/exec/exec_test.go +//this is not directly executed +func TestHelperProcess(*testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + args := os.Args + for len(args) > 0 { + if args[0] == "--" { + args = args[1:] + break + } + args = args[1:] + } + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "No command\n") + os.Exit(2) + } + + cmd, args := args[0], args[1:] + switch cmd { + case "/bin/bash": + o, _ := ioutil.ReadAll(os.Stdin) + fmt.Fprintf(os.Stdout, "Stdout: command %v - Stdin: %v\n", cmd, string(o)) + fmt.Fprintf(os.Stderr, "Stderr: command %v\n", cmd) + case "echo": + iargs := []interface{}{} + for _, s := range args { + iargs = append(iargs, s) + } + fmt.Println(iargs...) + fmt.Fprintf(os.Stderr, "Stderr: command %v\n", cmd) + default: + fmt.Fprintf(os.Stderr, "Unknown command %q\n", cmd) + os.Exit(2) + + } +} diff --git a/pkg/config/stepmeta_test.go b/pkg/config/stepmeta_test.go index 5d3a73c50..b08d04a9f 100644 --- a/pkg/config/stepmeta_test.go +++ b/pkg/config/stepmeta_test.go @@ -372,6 +372,5 @@ func TestGetContextDefaults(t *testing.T) { //no assert since we just want to make sure that no panic occurs }) - }) } diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go new file mode 100644 index 000000000..299e679c7 --- /dev/null +++ b/pkg/generator/step-metadata.go @@ -0,0 +1,312 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/SAP/jenkins-library/pkg/config" +) + +type stepInfo struct { + CobraCmdFuncName string + CreateCmdVar string + FlagsFunc string + Long string + Metadata []config.StepParameters + OSImport bool + Short string + StepFunc string + StepName string +} + +//StepGoTemplate ... +const stepGoTemplate = `package cmd + +import ( + //"os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" +) + +type {{ .StepName }}Options struct { + {{- range $key, $value := .Metadata }} + {{ $value.Name | golangName }} {{ $value.Type }} ` + "`json:\"{{$value.Name}},omitempty\"`" + `{{end}} +} + +var my{{ .StepName | title}}Options {{.StepName}}Options +var {{ .StepName }}StepConfigJSON string + +// {{.CobraCmdFuncName}} {{.Short}} +func {{.CobraCmdFuncName}}() *cobra.Command { + metadata := {{ .StepName }}Metadata() + var {{.CreateCmdVar}} = &cobra.Command{ + Use: "{{.StepName}}", + Short: "{{.Short}}", + Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, + PreRunE: func(cmd *cobra.Command, args []string) error { + return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, openPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return {{.StepName}}(my{{ .StepName | title }}Options) + }, + } + + {{.FlagsFunc}}({{.CreateCmdVar}}) + return {{.CreateCmdVar}} +} + +func {{.FlagsFunc}}(cmd *cobra.Command) { + {{- range $key, $value := .Metadata }} + cmd.Flags().{{ $value.Type | flagType }}(&my{{ $.StepName | title }}Options.{{ $value.Name | golangName }}, "{{ $value.Name }}", {{ $value.Default }}, "{{ $value.Description }}"){{ end }} + {{- printf "\n" }} + {{- range $key, $value := .Metadata }}{{ if $value.Mandatory }} + cmd.MarkFlagRequired("{{ $value.Name }}"){{ end }}{{ end }} +} + +// retrieve step metadata +func {{ .StepName }}Metadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {{- range $key, $value := .Metadata }} + { + Name: "{{ $value.Name }}", + Scope: []string{{ "{" }}{{ range $notused, $scope := $value.Scope }}"{{ $scope }}",{{ end }}{{ "}" }}, + Type: "{{ $value.Type }}", + Mandatory: {{ $value.Mandatory }}, + },{{ end }} + }, + }, + }, + } + return theMetaData +} +` + +//StepTestGoTemplate ... +const stepTestGoTemplate = `package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test{{.CobraCmdFuncName}}(t *testing.T) { + + testCmd := {{.CobraCmdFuncName}}() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "{{.StepName}}", testCmd.Use, "command name incorrect") + +} +` + +func main() { + + metadataPath := "./resources/metadata" + + metadataFiles, err := metadataFiles(metadataPath) + checkError(err) + + err = processMetaFiles(metadataFiles, openMetaFile, fileWriter) + checkError(err) + + cmd := exec.Command("go", "fmt", "./cmd") + err = cmd.Run() + checkError(err) + +} + +func processMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCloser, error), writeFile func(filename string, data []byte, perm os.FileMode) error) error { + for key := range metadataFiles { + + var stepData config.StepData + + configFilePath := metadataFiles[key] + + metadataFile, err := openFile(configFilePath) + checkError(err) + defer metadataFile.Close() + + fmt.Printf("Reading file %v\n", configFilePath) + + err = stepData.ReadPipelineStepData(metadataFile) + checkError(err) + + fmt.Printf("Step name: %v\n", stepData.Metadata.Name) + + err = setDefaultParameters(&stepData) + checkError(err) + + myStepInfo := getStepInfo(&stepData) + + step := stepTemplate(myStepInfo) + err = writeFile(fmt.Sprintf("cmd/%v_generated.go", stepData.Metadata.Name), step, 0644) + checkError(err) + + test := stepTestTemplate(myStepInfo) + err = writeFile(fmt.Sprintf("cmd/%v_generated_test.go", stepData.Metadata.Name), test, 0644) + checkError(err) + } + return nil +} + +func openMetaFile(name string) (io.ReadCloser, error) { + return os.Open(name) +} + +func fileWriter(filename string, data []byte, perm os.FileMode) error { + return ioutil.WriteFile(filename, data, perm) +} + +func setDefaultParameters(stepData *config.StepData) error { + //ToDo: custom function for default handling, support all relevant parameter types + for k, param := range stepData.Spec.Inputs.Parameters { + + if param.Default == nil { + switch param.Type { + case "string": + param.Default = fmt.Sprintf("os.Getenv(\"PIPER_%v\")", param.Name) + case "bool": + // ToDo: Check if default should be read from env + param.Default = "false" + case "[]string": + // ToDo: Check if default should be read from env + param.Default = "[]string{}" + default: + return fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + } + } else { + switch param.Type { + case "string": + param.Default = fmt.Sprintf("\"%v\"", param.Default) + case "bool": + boolVal := "false" + if param.Default.(bool) == true { + boolVal = "true" + } + param.Default = boolVal + case "[]string": + param.Default = fmt.Sprintf("[]string{\"%v\"}", strings.Join(param.Default.([]string), "\", \"")) + default: + return fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + } + } + + stepData.Spec.Inputs.Parameters[k] = param + } + return nil +} + +func getStepInfo(stepData *config.StepData) stepInfo { + return stepInfo{ + StepName: stepData.Metadata.Name, + CobraCmdFuncName: fmt.Sprintf("%vCommand", strings.Title(stepData.Metadata.Name)), + CreateCmdVar: fmt.Sprintf("create%vCmd", strings.Title(stepData.Metadata.Name)), + Short: stepData.Metadata.Description, + Long: stepData.Metadata.LongDescription, + Metadata: stepData.Spec.Inputs.Parameters, + FlagsFunc: fmt.Sprintf("add%vFlags", strings.Title(stepData.Metadata.Name)), + } +} + +func checkError(err error) { + if err != nil { + fmt.Printf("Error occured: %v\n", err) + os.Exit(1) + } +} + +func metadataFiles(sourceDirectory string) ([]string, error) { + + var metadataFiles []string + + err := filepath.Walk(sourceDirectory, func(path string, info os.FileInfo, err error) error { + if filepath.Ext(path) == ".yaml" { + metadataFiles = append(metadataFiles, path) + } + return nil + }) + if err != nil { + return metadataFiles, nil + } + return metadataFiles, nil +} + +func stepTemplate(myStepInfo stepInfo) []byte { + + funcMap := template.FuncMap{ + "flagType": flagType, + "golangName": golangName, + "title": strings.Title, + "longName": longName, + } + + tmpl, err := template.New("step").Funcs(funcMap).Parse(stepGoTemplate) + checkError(err) + + var generatedCode bytes.Buffer + err = tmpl.Execute(&generatedCode, myStepInfo) + checkError(err) + + return generatedCode.Bytes() +} + +func stepTestTemplate(myStepInfo stepInfo) []byte { + + funcMap := template.FuncMap{ + "flagType": flagType, + "golangName": golangName, + "title": strings.Title, + } + + tmpl, err := template.New("stepTest").Funcs(funcMap).Parse(stepTestGoTemplate) + checkError(err) + + var generatedCode bytes.Buffer + err = tmpl.Execute(&generatedCode, myStepInfo) + checkError(err) + + return generatedCode.Bytes() +} + +func longName(long string) string { + l := strings.ReplaceAll(long, "`", "` + \"`\" + `") + l = strings.TrimSpace(l) + return l +} + +func golangName(name string) string { + properName := strings.Replace(name, "Api", "API", -1) + properName = strings.Replace(properName, "Url", "URL", -1) + properName = strings.Replace(properName, "Id", "ID", -1) + properName = strings.Replace(properName, "Json", "JSON", -1) + properName = strings.Replace(properName, "json", "JSON", -1) + return strings.Title(properName) +} + +func flagType(paramType string) string { + var theFlagType string + switch paramType { + case "bool": + theFlagType = "BoolVar" + case "string": + theFlagType = "StringVar" + case "[]string": + theFlagType = "StringSliceVar" + default: + fmt.Printf("Meta data type not set or not known: '%v'\n", paramType) + os.Exit(1) + } + return theFlagType +} diff --git a/pkg/generator/step-metadata_test.go b/pkg/generator/step-metadata_test.go new file mode 100644 index 000000000..538c772f0 --- /dev/null +++ b/pkg/generator/step-metadata_test.go @@ -0,0 +1,227 @@ +package main + +import ( + //"bytes" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/stretchr/testify/assert" +) + +func configOpenFileMock(name string) (io.ReadCloser, error) { + meta1 := `metadata: + name: testStep + description: Test description + longDescription: | + Long Test description +spec: + inputs: + params: + - name: param0 + type: string + description: param0 description + default: val0 + scope: + - GENERAL + - PARAMETERS + mandatory: true + - name: param1 + type: string + description: param1 description + scope: + - PARAMETERS + - name: param2 + type: string + description: param1 description + scope: + - PARAMETERS + mandatory: true +` + var r string + switch name { + case "test.yaml": + r = meta1 + default: + r = "" + } + return ioutil.NopCloser(strings.NewReader(r)), nil +} + +var files map[string][]byte + +func writeFileMock(filename string, data []byte, perm os.FileMode) error { + if files == nil { + files = make(map[string][]byte) + } + files[filename] = data + return nil +} + +func TestProcessMetaFiles(t *testing.T) { + + processMetaFiles([]string{"test.yaml"}, configOpenFileMock, writeFileMock) + + t.Run("step code", func(t *testing.T) { + goldenFilePath := filepath.Join("testdata", t.Name()+"_generated.golden") + expected, err := ioutil.ReadFile(goldenFilePath) + if err != nil { + t.Fatalf("failed reading %v", goldenFilePath) + } + assert.Equal(t, expected, files["cmd/testStep_generated.go"]) + }) + + t.Run("test code", func(t *testing.T) { + goldenFilePath := filepath.Join("testdata", t.Name()+"_generated.golden") + expected, err := ioutil.ReadFile(goldenFilePath) + if err != nil { + t.Fatalf("failed reading %v", goldenFilePath) + } + assert.Equal(t, expected, files["cmd/testStep_generated_test.go"]) + }) +} + +func TestSetDefaultParameters(t *testing.T) { + t.Run("success case", func(t *testing.T) { + stepData := config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "param0", Scope: []string{"GENERAL"}, Type: "string", Default: "val0"}, + {Name: "param1", Scope: []string{"STEPS"}, Type: "string"}, + {Name: "param2", Scope: []string{"STAGES"}, Type: "bool", Default: true}, + {Name: "param3", Scope: []string{"PARAMETERS"}, Type: "bool"}, + {Name: "param4", Scope: []string{"ENV"}, Type: "[]string", Default: []string{"val4_1", "val4_2"}}, + {Name: "param5", Scope: []string{"ENV"}, Type: "[]string"}, + }, + }, + }, + } + + expected := []string{ + "\"val0\"", + "os.Getenv(\"PIPER_param1\")", + "true", + "false", + "[]string{\"val4_1\", \"val4_2\"}", + "[]string{}", + } + + err := setDefaultParameters(&stepData) + + assert.NoError(t, err, "error occured but none expected") + + for k, v := range expected { + assert.Equal(t, v, stepData.Spec.Inputs.Parameters[k].Default, fmt.Sprintf("default not correct for parameter %v", k)) + } + }) + + t.Run("error case", func(t *testing.T) { + stepData := []config.StepData{ + { + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "param0", Scope: []string{"GENERAL"}, Type: "int", Default: 10}, + {Name: "param1", Scope: []string{"GENERAL"}, Type: "int"}, + }, + }, + }, + }, + { + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "param1", Scope: []string{"GENERAL"}, Type: "int"}, + }, + }, + }, + }, + } + + for k, v := range stepData { + err := setDefaultParameters(&v) + assert.Error(t, err, fmt.Sprintf("error expected but none occured for parameter %v", k)) + } + }) +} + +func TestGetStepInfo(t *testing.T) { + + stepData := config.StepData{ + Metadata: config.StepMetadata{ + Name: "testStep", + Description: "Test description", + LongDescription: "Long Test description", + }, + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {Name: "param0", Scope: []string{"GENERAL"}, Type: "string", Default: "test"}, + }, + }, + }, + } + + myStepInfo := getStepInfo(&stepData) + + assert.Equal(t, "testStep", myStepInfo.StepName, "StepName incorrect") + assert.Equal(t, "TestStepCommand", myStepInfo.CobraCmdFuncName, "CobraCmdFuncName incorrect") + assert.Equal(t, "createTestStepCmd", myStepInfo.CreateCmdVar, "CreateCmdVar incorrect") + assert.Equal(t, "Test description", myStepInfo.Short, "Short incorrect") + assert.Equal(t, "Long Test description", myStepInfo.Long, "Long incorrect") + assert.Equal(t, stepData.Spec.Inputs.Parameters, myStepInfo.Metadata, "Metadata incorrect") + assert.Equal(t, "addTestStepFlags", myStepInfo.FlagsFunc, "FlagsFunc incorrect") + +} + +func TestLongName(t *testing.T) { + tt := []struct { + input string + expected string + }{ + {input: "my long name with no ticks", expected: "my long name with no ticks"}, + {input: "my long name with `ticks`", expected: "my long name with ` + \"`\" + `ticks` + \"`\" + `"}, + } + + for k, v := range tt { + assert.Equal(t, v.expected, longName(v.input), fmt.Sprintf("wrong long name for run %v", k)) + } +} + +func TestGolangName(t *testing.T) { + tt := []struct { + input string + expected string + }{ + {input: "testApi", expected: "TestAPI"}, + {input: "testUrl", expected: "TestURL"}, + {input: "testId", expected: "TestID"}, + {input: "testJson", expected: "TestJSON"}, + {input: "jsonTest", expected: "JSONTest"}, + } + + for k, v := range tt { + assert.Equal(t, v.expected, golangName(v.input), fmt.Sprintf("wrong golang name for run %v", k)) + } +} + +func TestFlagType(t *testing.T) { + tt := []struct { + input string + expected string + }{ + {input: "bool", expected: "BoolVar"}, + {input: "string", expected: "StringVar"}, + {input: "[]string", expected: "StringSliceVar"}, + } + + for k, v := range tt { + assert.Equal(t, v.expected, flagType(v.input), fmt.Sprintf("wrong flag type for run %v", k)) + } +} diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden new file mode 100644 index 000000000..5aea334dd --- /dev/null +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -0,0 +1,76 @@ +package cmd + +import ( + //"os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" +) + +type testStepOptions struct { + Param0 string `json:"param0,omitempty"` + Param1 string `json:"param1,omitempty"` + Param2 string `json:"param2,omitempty"` +} + +var myTestStepOptions testStepOptions +var testStepStepConfigJSON string + +// TestStepCommand Test description +func TestStepCommand() *cobra.Command { + metadata := testStepMetadata() + var createTestStepCmd = &cobra.Command{ + Use: "testStep", + Short: "Test description", + Long: `Long Test description`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return PrepareConfig(cmd, &metadata, "testStep", &myTestStepOptions, openPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return testStep(myTestStepOptions) + }, + } + + addTestStepFlags(createTestStepCmd) + return createTestStepCmd +} + +func addTestStepFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&myTestStepOptions.Param0, "param0", "val0", "param0 description") + cmd.Flags().StringVar(&myTestStepOptions.Param1, "param1", os.Getenv("PIPER_param1"), "param1 description") + cmd.Flags().StringVar(&myTestStepOptions.Param2, "param2", os.Getenv("PIPER_param2"), "param1 description") + + cmd.MarkFlagRequired("param0") + cmd.MarkFlagRequired("param2") +} + +// retrieve step metadata +func testStepMetadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + { + Name: "param0", + Scope: []string{"GENERAL","PARAMETERS",}, + Type: "string", + Mandatory: true, + }, + { + Name: "param1", + Scope: []string{"PARAMETERS",}, + Type: "string", + Mandatory: false, + }, + { + Name: "param2", + Scope: []string{"PARAMETERS",}, + Type: "string", + Mandatory: true, + }, + }, + }, + }, + } + return theMetaData +} diff --git a/pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden new file mode 100644 index 000000000..df5a4fb8c --- /dev/null +++ b/pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden @@ -0,0 +1,16 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTestStepCommand(t *testing.T) { + + testCmd := TestStepCommand() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "testStep", testCmd.Use, "command name incorrect") + +} diff --git a/resources/metadata/karma.yaml b/resources/metadata/karma.yaml new file mode 100644 index 000000000..f10a58bfa --- /dev/null +++ b/resources/metadata/karma.yaml @@ -0,0 +1,67 @@ +metadata: + name: karmaExecuteTests + description: Executes the Karma test runner + longDescription: | + In this step the ([Karma test runner](http://karma-runner.github.io)) is executed. + + The step is using the `seleniumExecuteTest` step to spin up two containers in a Docker network: + + * a Selenium/Chrome container (`selenium/standalone-chrome`) + * a NodeJS container (`node:8-stretch`) + + In the Docker network, the containers can be referenced by the values provided in `dockerName` and `sidecarName`, the default values are `karma` and `selenium`. These values must be used in the `hostname` properties of the test configuration ([Karma](https://karma-runner.github.io/1.0/config/configuration-file.html) and [WebDriver](https://github.com/karma-runner/karma-webdriver-launcher#usage)). + + !!! note + In a Kubernetes environment, the containers both need to be referenced with `localhost`. +spec: + inputs: + resources: + - name: buildDescriptor + type: stash + - name: tests + type: stash + params: + - name: installCommand + type: string + description: The command that is executed to install the test tool. + default: npm install --quiet + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + mandatory: true + - name: modulePath + type: string + description: Define the path of the module to execute tests on. + default: '.' + scope: + - PARAMETERS + - STAGES + - STEPS + mandatory: true + - name: runCommand + type: string + description: The command that is executed to start the tests. + default: npm run karma + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + mandatory: true + #outputs: + containers: + - name: maven + image: maven:3.5-jdk-8 + volumeMounts: + - mountPath: /dev/shm + name: dev-shm + sidecars: + - image: selenium/standalone-chrome + name: selenium + securityContext: + privileged: true + volumeMounts: + - mountPath: /dev/shm + name: dev-shm From 84ad9d49e6e9ad4842ad2f32891a5c0fd9ec03fe Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 25 Oct 2019 15:44:58 +0200 Subject: [PATCH 082/141] Enhance CONTRIBUTING.md (#916) close #213 --- .github/CONTRIBUTING.md | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b6c1b90c4..92b76d43d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,13 @@ # Guidance on how to contribute +**Table of contents:** + +1. [Using the issue tracker](#using-the-issue-tracker) +1. [Changing the code-base](#changing-the-code-base) +1. [Jenkins credential handling](#jenkins-credentials) +1. [Code Style](#code-style) +1. [References](#references) + There are two primary ways to help: * Using the issue tracker, and @@ -36,14 +44,6 @@ Implementation of a functionality and its documentation shall happen within the Pipeline steps must not make use of return values. The pattern for sharing parameters between pipeline steps or between a pipeline step and a pipeline script is sharing values via the [`commonPipelineEnvironment`](../vars/commonPipelineEnvironment.groovy). Since there is no return value from a pipeline step the return value of a pipeline step is already `void` rather than `def`. -### Code Style - -The code should follow any stylistic and architectural guidelines prescribed by the project. In the absence of guidelines, mimic the styles and patterns in the existing code-base. - -Variables, methods, types and so on shall have meaningful self describing names. Doing so makes understanding code easier and requires less commenting. It helps people who did not write the code to understand it better. - -Code shall contain comments to explain the intention of the code when it is unclear what the intention of the author was. In such cases, comments should describe the "why" and not the "what" (that is in the code already). - #### EditorConfig To ensure a common file format, there is a `.editorConfig` file [in place](../.editorconfig). To respect this file, [check](http://editorconfig.org/#download) if your editor does support it natively or you need to download a plugin. @@ -54,8 +54,25 @@ Write [meaningful commit messages](http://who-t.blogspot.de/2009/12/on-commit-me Good commit messages speed up the review process and help to keep this project maintainable in the long term. +## Jenkins credential handling + +References to Jenkins credentials should have meaningful names. + +We are using the following approach for naming Jenkins credentials: + +For username/password credentials: +`CredentialsId` like e.g. `neoCredentialsId` + +For other cases we add further information to the name like: + +* `gitSshCredentialsId` for ssh credentials +* `githubTokenCredentialsId`for token/string credentials +* `gcpFileCredentialsId` for file credentials + ## Code Style +Generally, the code should follow any stylistic and architectural guidelines prescribed by the project. In the absence of guidelines, mimic the styles and patterns in the existing code-base. + The intention of this section is to describe the code style for this project. As reference document, the [Groovy's style guide](http://groovy-lang.org/style-guide.html) was taken. For further reading about Groovy's syntax and examples, please refer to this guide. This project is intended to run in Jenkins [[2]](https://jenkins.io/doc/book/getting-started/) as part of a Jenkins Pipeline [[3]](https://jenkins.io/doc/book/pipeline/). It is composed by Jenkins Pipeline's syntax, Groovy's syntax and Java's syntax. @@ -64,6 +81,12 @@ Some Groovy's syntax is not yet supported by Jenkins. It is also the intention o As Groovy supports 99% of Java’s syntax [[1]](http://groovy-lang.org/style-guide.html), many Java developers tend to write Groovy code using Java's syntax. Such a developer should also consider the following code style for this project. +### General remarks + +Variables, methods, types and so on shall have meaningful self describing names. Doing so makes understanding code easier and requires less commenting. It helps people who did not write the code to understand it better. + +Code shall contain comments to explain the intention of the code when it is unclear what the intention of the author was. In such cases, comments should describe the "why" and not the "what" (that is in the code already). + ### Omit semicolons ### Use the return keyword @@ -177,7 +200,7 @@ If the type of the exception thrown inside a try block is not important, catch a To check parameters, return values, and more, use the assert statement. -## Reference +## References [1] Groovy's syntax: [http://groovy-lang.org/style-guide.html](http://groovy-lang.org/style-guide.html) From 462c293c9c1649d34231c6a74a2755d19705db22 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Fri, 25 Oct 2019 17:49:54 +0200 Subject: [PATCH 083/141] User piper docker images (#920) --- .../docs/steps/dockerExecuteOnKubernetes.md | 2 +- resources/default_pipeline_environment.yml | 6 +-- test/groovy/CloudFoundryDeployTest.groovy | 42 +++++++++---------- test/groovy/MulticloudDeployTest.groovy | 2 +- vars/dockerExecuteOnKubernetes.groovy | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/documentation/docs/steps/dockerExecuteOnKubernetes.md b/documentation/docs/steps/dockerExecuteOnKubernetes.md index b64aaac16..a9f3a8dc3 100644 --- a/documentation/docs/steps/dockerExecuteOnKubernetes.md +++ b/documentation/docs/steps/dockerExecuteOnKubernetes.md @@ -46,7 +46,7 @@ export ON_K8S=true" ``` ```groovy -dockerExecuteOnKubernetes(script: script, containerMap: ['maven:3.5-jdk-8-alpine': 'maven', 's4sdk/docker-cf-cli': 'cfcli']){ +dockerExecuteOnKubernetes(script: script, containerMap: ['maven:3.5-jdk-8-alpine': 'maven', 'ppiper/cf-cli': 'cfcli']){ container('maven'){ sh "mvn clean install" } diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 25a1fae41..4b6af4620 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -167,10 +167,10 @@ steps: - 'deployDescriptor' - 'pipelineConfigAndTests' cf_native: - dockerImage: 's4sdk/docker-cf-cli' + dockerImage: 'ppiper/cf-cli' dockerWorkspace: '/home/piper' mtaDeployPlugin: - dockerImage: 's4sdk/docker-cf-cli' + dockerImage: 'ppiper/cf-cli' dockerWorkspace: '/home/piper' containerExecuteStructureTests: containerCommand: '/busybox/tail -f /dev/null' @@ -328,7 +328,7 @@ steps: mtaJarLocation: '/opt/sap/mta/lib/mta.jar' dockerImage: 'ppiper/mta-archive-builder' neoDeploy: - dockerImage: 's4sdk/docker-neo-cli' + dockerImage: 'ppiper/neo-cli' deployMode: 'mta' warAction: 'deploy' extensions: [] diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy index 0bbd37afb..bfd8d4f63 100644 --- a/test/groovy/CloudFoundryDeployTest.groovy +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -152,7 +152,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -204,7 +204,7 @@ class CloudFoundryDeployTest extends BasePiperTest { ] ]) // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -278,7 +278,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -306,7 +306,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -335,7 +335,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -373,7 +373,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -469,7 +469,7 @@ class CloudFoundryDeployTest extends BasePiperTest { mtaPath: 'target/test.mtar' ]) // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) assertThat(shellRule.shell, hasItem(containsString('cf deploy target/test.mtar -f'))) @@ -545,7 +545,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifestVariablesFiles: ['vars.yml'] ]) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -558,7 +558,7 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfPushDeploymentWithVariableSubstitutionFromNotExistingFilePrintsWarning() { readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") - fileExistsRule.registerExistingFile('test.yml') + fileExistsRule.registerExistingFile('test.yml') stepRule.step.cloudFoundryDeploy([ script: nullScript, @@ -574,15 +574,15 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) // asserts - assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf push testAppName -f 'test.yml'"))) assertThat(loggingRule.log, containsString("[WARNING] We skip adding not-existing file 'vars.yml' as a vars-file to the cf create-service-push call")) } @Test void testCfPushDeploymentWithVariableSubstitutionFromVarsList() { readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") - List varsList = [["appName" : "testApplicationFromVarsList"]] - + List varsList = [["appName" : "testApplicationFromVarsList"]] + stepRule.step.cloudFoundryDeploy([ script: nullScript, juStabUtils: utils, @@ -597,7 +597,7 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -609,8 +609,8 @@ class CloudFoundryDeployTest extends BasePiperTest { @Test void testCfPushDeploymentWithVariableSubstitutionFromVarsListNotAList() { - readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") - + readYamlRule.registerYaml('test.yml', "applications: [[name: '((appName))']]") + thrown.expect(hudson.AbortException) thrown.expectMessage('[cloudFoundryDeploy] ERROR: Parameter config.cloudFoundry.manifestVariables is not a List!') @@ -626,7 +626,7 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml', cfManifestVariables: 'notAList' ]) - + } @Test @@ -650,7 +650,7 @@ class CloudFoundryDeployTest extends BasePiperTest { ]) // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -674,8 +674,8 @@ class CloudFoundryDeployTest extends BasePiperTest { cfManifest: 'test.yml' ]) - // asserts - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + // asserts + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -720,7 +720,7 @@ class CloudFoundryDeployTest extends BasePiperTest { assertNotNull(testYamlData) assertThat(testYamlData.get("applications").get(0).get(0).get("name"), is("testApplication")) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) @@ -766,7 +766,7 @@ class CloudFoundryDeployTest extends BasePiperTest { assertNotNull(testYamlData) assertThat(testYamlData.get("applications").get(0).get(0).get("name"), is("testApplicationFromVarsList")) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 's4sdk/docker-cf-cli')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(dockerExecuteRule.dockerParams.dockerEnvVars, hasEntry('STATUS_CODE', "${200}")) assertThat(shellRule.shell, hasItem(containsString('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"'))) diff --git a/test/groovy/MulticloudDeployTest.groovy b/test/groovy/MulticloudDeployTest.groovy index e70bf7650..e0451ff2e 100644 --- a/test/groovy/MulticloudDeployTest.groovy +++ b/test/groovy/MulticloudDeployTest.groovy @@ -92,7 +92,7 @@ class MulticloudDeployTest extends BasePiperTest { deployType: 'blue-green', keepOldInstance: true, cf_native: [ - dockerImage: 's4sdk/docker-cf-cli', + dockerImage: 'ppiper/cf-cli', dockerWorkspace: '/home/piper' ] ] diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy index 885c7a14c..9781a099c 100644 --- a/vars/dockerExecuteOnKubernetes.groovy +++ b/vars/dockerExecuteOnKubernetes.groovy @@ -39,7 +39,7 @@ import hudson.AbortException 'containerEnvVars', /** * A map of docker image to the name of the container. The pod will be created with all the images from this map and they are labled based on the value field of each map entry. - * Example: `['maven:3.5-jdk-8-alpine': 'mavenExecute', 'selenium/standalone-chrome': 'selenium', 'famiko/jmeter-base': 'checkJMeter', 's4sdk/docker-cf-cli': 'cloudfoundry']` + * Example: `['maven:3.5-jdk-8-alpine': 'mavenExecute', 'selenium/standalone-chrome': 'selenium', 'famiko/jmeter-base': 'checkJMeter', 'ppiper/cf-cli': 'cloudfoundry']` */ 'containerMap', /** From 2f00e36682e6860e78ca66c88b5d7fb297e5bb43 Mon Sep 17 00:00:00 2001 From: Irina Kirilova Date: Mon, 28 Oct 2019 12:05:52 +0100 Subject: [PATCH 084/141] correct typos in xsDeploy documentation (#921) --- documentation/docs/steps/xsDeploy.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/documentation/docs/steps/xsDeploy.md b/documentation/docs/steps/xsDeploy.md index 0e3c8b4a9..5395d617d 100644 --- a/documentation/docs/steps/xsDeploy.md +++ b/documentation/docs/steps/xsDeploy.md @@ -21,7 +21,7 @@ xsDeploy credentialsId: 'my-credentials-id', apiUrl: 'https://example.org/xs', space: 'mySpace', - org:: 'myOrg' + org: 'myOrg' ``` Example configuration: @@ -30,12 +30,11 @@ Example configuration: steps: <...> xsDeploy: - script: this, mtaPath: path/to/archiveFile.mtar credentialsId: my-credentials-id apiUrl: https://example.org/xs space: mySpace - org:: myOrg + org: myOrg ``` [dockerExecute]: ../dockerExecute From bb59e68df31a45317f195acc4ad854a0fda5fb62 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Mon, 28 Oct 2019 12:55:27 +0100 Subject: [PATCH 085/141] sonarExecuteScan: Fix links in documentation (#883) * Update sonarExecuteScan.groovy * Update sonarExecuteScan.groovy * Update vars/sonarExecuteScan.groovy * Update vars/sonarExecuteScan.groovy --- vars/sonarExecuteScan.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index 5fcf1dac6..02639758c 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -14,7 +14,7 @@ import java.nio.charset.StandardCharsets @Field Set GENERAL_CONFIG_KEYS = [ /** * Pull-Request voting only: - * The URL to the Github API. see https://docs.sonarqube.org/display/PLUG/GitHub+Plugin#GitHubPlugin-Usage + * The URL to the Github API. see [GitHub plugin docs](https://docs.sonarqube.org/display/PLUG/GitHub+Plugin#GitHubPlugin-Usage) * deprecated: only supported in LTS / < 7.2 */ 'githubApiUrl', @@ -38,7 +38,7 @@ import java.nio.charset.StandardCharsets */ 'githubTokenCredentialsId', /** - * The Jenkins credentialsId for a SonarQube token. It is needed for non-anonymous analysis runs. see https://sonarcloud.io/account/security + * The Jenkins credentialsId for a SonarQube token. It is needed for non-anonymous analysis runs. see [SonarQube docs](https://docs.sonarqube.org/latest/user-guide/user-token/) * @possibleValues Jenkins credential id */ 'sonarTokenCredentialsId', @@ -62,7 +62,7 @@ import java.nio.charset.StandardCharsets 'disableInlineComments', /** * Name of the docker image that should be used. If empty, Docker is not used and the command is executed directly on the Jenkins system. - * see dockerExecute + * see [dockerExecute](dockerExecute.md) */ 'dockerImage', /** From 0f3436d1a5a3b4ec7ec45b9a76ad74a5725b573a Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Mon, 28 Oct 2019 13:01:51 +0100 Subject: [PATCH 086/141] sonarExecuteScan: avoid working directory being deleted before scan detection ends (#882) --- vars/sonarExecuteScan.groovy | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index 02639758c..b82f96926 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -120,21 +120,22 @@ void call(Map parameters = [:]) { configuration.options = [].plus(configuration.options) def worker = { config -> - withSonarQubeEnv(config.instance) { - try{ - loadSonarScanner(config) + try { + withSonarQubeEnv(config.instance) { - loadCertificates(config) + loadSonarScanner(config) - if(config.organization) config.options.add("sonar.organization=${config.organization}") - if(config.projectVersion) config.options.add("sonar.projectVersion=${config.projectVersion}") - // prefix options - config.options = config.options.collect { it.startsWith('-D') ? it : "-D${it}" } + loadCertificates(config) - sh "PATH=\$PATH:${env.WORKSPACE}/.sonar-scanner/bin sonar-scanner ${config.options.join(' ')}" - }finally{ - sh 'rm -rf .sonar-scanner .certificates .scannerwork' + if(config.organization) config.options.add("sonar.organization=${config.organization}") + if(config.projectVersion) config.options.add("sonar.projectVersion=${config.projectVersion}") + // prefix options + config.options = config.options.collect { it.startsWith('-D') ? it : "-D${it}" } + + sh "PATH=\$PATH:${env.WORKSPACE}/.sonar-scanner/bin sonar-scanner ${config.options.join(' ')}" } + } finally { + sh 'rm -rf .sonar-scanner .certificates .scannerwork' } } From 800eeffeaedf28720808ae0341c17aff14831125 Mon Sep 17 00:00:00 2001 From: Thorsten Duda Date: Mon, 28 Oct 2019 13:56:11 +0100 Subject: [PATCH 087/141] remove double navigation entry --- documentation/mkdocs.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 5a7397361..a17a00ad4 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -78,23 +78,6 @@ nav: - uiVeri5ExecuteTests: steps/uiVeri5ExecuteTests.md - whitesourceExecuteScan: steps/whitesourceExecuteScan.md - xsDeploy: steps/xsDeploy.md - - 'Pipelines': - - 'General purpose pipeline': - - 'Introduction': stages/introduction.md - - 'Examples': stages/examples.md - - 'Stages': - - 'Init Stage': stages/init.md - - 'Pull-Request Voting Stage': stages/prvoting.md - - 'Build Stage': stages/build.md - - 'Additional Unit Test Stage': stages/additionalunittests.md - - 'Integration Stage': stages/integration.md - - 'Acceptance Stage': stages/acceptance.md - - 'Security Stage': stages/security.md - - 'Performance Stage': stages/performance.md - - 'Compliance': stages/compliance.md - - 'Confirm Stage': stages/confirm.md - - 'Promote Stage': stages/promote.md - - 'Release Stage': stages/release.md - 'Scenarios': - 'Build and Deploy Hybrid Applications with Jenkins and SAP Solution Manager': scenarios/changeManagement.md - 'Build and Deploy SAP UI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins': scenarios/ui5-sap-cp/Readme.md From 6604a82eb1b02f24ed1680df8d1be446a3def599 Mon Sep 17 00:00:00 2001 From: Thorsten Duda Date: Mon, 28 Oct 2019 16:47:57 +0100 Subject: [PATCH 088/141] remove double scenarios entry --- documentation/mkdocs.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index a17a00ad4..7b63bf062 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -78,11 +78,6 @@ nav: - uiVeri5ExecuteTests: steps/uiVeri5ExecuteTests.md - whitesourceExecuteScan: steps/whitesourceExecuteScan.md - xsDeploy: steps/xsDeploy.md - - 'Scenarios': - - 'Build and Deploy Hybrid Applications with Jenkins and SAP Solution Manager': scenarios/changeManagement.md - - 'Build and Deploy SAP UI5 or SAP Fiori Applications on SAP Cloud Platform with Jenkins': scenarios/ui5-sap-cp/Readme.md - - 'Build and Deploy Applications with Jenkins and the SAP Cloud Application Programming Model': scenarios/CAP_Scenario.md - - 'Build and Deploy SAP Fiori Applications for SAP HANA XS Advanced ': scenarios/xsa-deploy/Readme.md - Resources: - 'Required Plugins': jenkins/requiredPlugins.md From e01b3327fd4c102838a3f69ef6794b165caa0203 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Tue, 29 Oct 2019 10:58:24 +0100 Subject: [PATCH 089/141] Go configuration - add aliasing functionality (#925) * Go configuration - add aliasing functionality This assists in backward compatibility cases. It is now possible to define per parameter aliases. --- cmd/getConfig.go | 7 +- cmd/piper.go | 2 +- pkg/config/config.go | 47 ++++++++++++- pkg/config/config_test.go | 130 +++++++++++++++++++++++++++++++++++- pkg/config/stepmeta.go | 19 +++++- pkg/config/stepmeta_test.go | 17 +++++ 6 files changed, 215 insertions(+), 7 deletions(-) diff --git a/cmd/getConfig.go b/cmd/getConfig.go index ad8fc78f0..bbc4cc799 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -73,7 +73,12 @@ func generateConfig() error { var flags map[string]interface{} - stepConfig, err = myConfig.GetStepConfig(flags, generalConfig.parametersJSON, customConfig, defaultConfig, paramFilter, generalConfig.stageName, configOptions.stepName) + params := []config.StepParameters{} + if !configOptions.contextConfig { + params = metadata.Spec.Inputs.Parameters + } + + stepConfig, err = myConfig.GetStepConfig(flags, generalConfig.parametersJSON, customConfig, defaultConfig, paramFilter, params, generalConfig.stageName, configOptions.stepName) if err != nil { return errors.Wrap(err, "getting step config failed") } diff --git a/cmd/piper.go b/cmd/piper.go index 328655ec2..b868a5ecc 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -84,7 +84,7 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin } var err error - stepConfig, err = myConfig.GetStepConfig(flagValues, generalConfig.parametersJSON, customConfig, defaultConfig, filters, generalConfig.stageName, stepName) + stepConfig, err = myConfig.GetStepConfig(flagValues, generalConfig.parametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, generalConfig.stageName, stepName) if err != nil { return errors.Wrap(err, "retrieving step configuration failed") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7dae04d83..622e9dffb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "os" + "strings" "github.com/ghodss/yaml" "github.com/pkg/errors" @@ -39,8 +40,44 @@ func (c *Config) ReadConfig(configuration io.ReadCloser) error { return nil } +// ApplyAliasConfig adds configuration values available on aliases to primary configuration parameters +func (c *Config) ApplyAliasConfig(parameters []StepParameters, filters StepFilters, stageName, stepName string) { + for _, p := range parameters { + c.General = setParamValueFromAlias(c.General, filters.General, p) + if c.Stages[stageName] != nil { + c.Stages[stageName] = setParamValueFromAlias(c.Stages[stageName], filters.Stages, p) + } + if c.Steps[stepName] != nil { + c.Steps[stepName] = setParamValueFromAlias(c.Steps[stepName], filters.Steps, p) + } + } +} + +func setParamValueFromAlias(configMap map[string]interface{}, filter []string, p StepParameters) map[string]interface{} { + if configMap[p.Name] == nil && sliceContains(filter, p.Name) { + for _, a := range p.Aliases { + configMap[p.Name] = getDeepAliasValue(configMap, a.Name) + if configMap[p.Name] != nil { + return configMap + } + } + } + return configMap +} + +func getDeepAliasValue(configMap map[string]interface{}, key string) interface{} { + parts := strings.Split(key, "/") + if len(parts) > 1 { + if configMap[parts[0]] == nil { + return nil + } + return getDeepAliasValue(configMap[parts[0]].(map[string]interface{}), strings.Join(parts[1:], "/")) + } + return configMap[key] +} + // GetStepConfig provides merged step configuration using defaults, config, if available -func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON string, configuration io.ReadCloser, defaults []io.ReadCloser, filters StepFilters, stageName, stepName string) (StepConfig, error) { +func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON string, configuration io.ReadCloser, defaults []io.ReadCloser, filters StepFilters, parameters []StepParameters, stageName, stepName string) (StepConfig, error) { var stepConfig StepConfig var d PipelineDefaults @@ -52,6 +89,7 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri //ignoring unavailability of config file since considered optional } } + c.ApplyAliasConfig(parameters, filters, stageName, stepName) if err := d.ReadPipelineDefaults(defaults); err != nil { switch err.(type) { @@ -64,6 +102,7 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri // first: read defaults & merge general -> steps (-> general -> steps ...) for _, def := range d.Defaults { + def.ApplyAliasConfig(parameters, filters, stageName, stepName) stepConfig.mixIn(def.General, filters.General) stepConfig.mixIn(def.Steps[stepName], filters.Steps) } @@ -80,6 +119,12 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri if len(paramJSON) != 0 { var params map[string]interface{} json.Unmarshal([]byte(paramJSON), ¶ms) + + //apply aliases + for _, p := range parameters { + params = setParamValueFromAlias(params, filters.Parameters, p) + } + stepConfig.mixIn(params, filters.Parameters) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7e9a2aedb..3c81bad76 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -112,7 +112,7 @@ steps: defaults := []io.ReadCloser{ioutil.NopCloser(strings.NewReader(defaults1)), ioutil.NopCloser(strings.NewReader(defaults2))} myConfig := ioutil.NopCloser(strings.NewReader(testConfig)) - stepConfig, err := c.GetStepConfig(flags, paramJSON, myConfig, defaults, filters, "stage1", "step1") + stepConfig, err := c.GetStepConfig(flags, paramJSON, myConfig, defaults, filters, []StepParameters{}, "stage1", "step1") assert.Equal(t, nil, err, "error occured but none expected") @@ -151,7 +151,7 @@ steps: t.Run("Failure case config", func(t *testing.T) { var c Config myConfig := ioutil.NopCloser(strings.NewReader("invalid config")) - _, err := c.GetStepConfig(nil, "", myConfig, nil, StepFilters{}, "stage1", "step1") + _, err := c.GetStepConfig(nil, "", myConfig, nil, StepFilters{}, []StepParameters{}, "stage1", "step1") assert.EqualError(t, err, "failed to parse custom pipeline configuration: error unmarshalling \"invalid config\": error unmarshaling JSON: json: cannot unmarshal string into Go value of type config.Config", "default error expected") }) @@ -159,7 +159,7 @@ steps: var c Config myConfig := ioutil.NopCloser(strings.NewReader("")) myDefaults := []io.ReadCloser{ioutil.NopCloser(strings.NewReader("invalid defaults"))} - _, err := c.GetStepConfig(nil, "", myConfig, myDefaults, StepFilters{}, "stage1", "step1") + _, err := c.GetStepConfig(nil, "", myConfig, myDefaults, StepFilters{}, []StepParameters{}, "stage1", "step1") assert.EqualError(t, err, "failed to parse pipeline default configuration: error unmarshalling \"invalid defaults\": error unmarshaling JSON: json: cannot unmarshal string into Go value of type config.Config", "default error expected") }) @@ -187,6 +187,130 @@ func TestGetStepConfigWithJSON(t *testing.T) { }) } +func TestApplyAliasConfig(t *testing.T) { + p := []StepParameters{ + { + Name: "p0", + Aliases: []Alias{ + {Name: "p0_notused"}, + }, + }, + { + Name: "p1", + Aliases: []Alias{ + {Name: "p1_alias"}, + }, + }, + { + Name: "p2", + Aliases: []Alias{ + {Name: "p2_alias/deep/test"}, + }, + }, + { + Name: "p3", + Aliases: []Alias{ + {Name: "p3_notused"}, + }, + }, + { + Name: "p4", + Aliases: []Alias{ + {Name: "p4_alias"}, + {Name: "p4_2nd_alias"}, + }, + }, + { + Name: "p5", + Aliases: []Alias{ + {Name: "p5_notused"}, + }, + }, + { + Name: "p6", + Aliases: []Alias{ + {Name: "p6_1st_alias"}, + {Name: "p6_alias"}, + }, + }, + } + + filters := StepFilters{ + General: []string{"p1", "p2"}, + Stages: []string{"p4"}, + Steps: []string{"p6"}, + } + + c := Config{ + General: map[string]interface{}{ + "p0_notused": "p0_general", + "p1_alias": "p1_general", + "p2_alias": map[string]interface{}{ + "deep": map[string]interface{}{ + "test": "p2_general", + }, + }, + }, + Stages: map[string]map[string]interface{}{ + "stage1": map[string]interface{}{ + "p3_notused": "p3_stage", + "p4_alias": "p4_stage", + }, + }, + Steps: map[string]map[string]interface{}{ + "step1": map[string]interface{}{ + "p5_notused": "p5_step", + "p6_alias": "p6_step", + }, + }, + } + + c.ApplyAliasConfig(p, filters, "stage1", "step1") + + t.Run("Global", func(t *testing.T) { + assert.Nil(t, c.General["p0"]) + assert.Equal(t, "p1_general", c.General["p1"]) + assert.Equal(t, "p2_general", c.General["p2"]) + }) + + t.Run("Stage", func(t *testing.T) { + assert.Nil(t, c.General["p3"]) + assert.Equal(t, "p4_stage", c.Stages["stage1"]["p4"]) + }) + + t.Run("Stage", func(t *testing.T) { + assert.Nil(t, c.General["p5"]) + assert.Equal(t, "p6_step", c.Steps["step1"]["p6"]) + }) + +} + +func TestGetDeepAliasValue(t *testing.T) { + c := map[string]interface{}{ + "p0": "p0_val", + "p1": 11, + "p2": map[string]interface{}{ + "p2_0": "p2_0_val", + "p2_1": map[string]interface{}{ + "p2_1_0": "p2_1_0_val", + }, + }, + } + tt := []struct { + key string + expected interface{} + }{ + {key: "p0", expected: "p0_val"}, + {key: "p1", expected: 11}, + {key: "p2/p2_0", expected: "p2_0_val"}, + {key: "p2/p2_1/p2_1_0", expected: "p2_1_0_val"}, + } + + for k, v := range tt { + assert.Equal(t, v.expected, getDeepAliasValue(c, v.key), fmt.Sprintf("wrong return value for run %v", k+1)) + } +} + func TestGetJSON(t *testing.T) { t.Run("Success case", func(t *testing.T) { diff --git a/pkg/config/stepmeta.go b/pkg/config/stepmeta.go index ecab79887..11d764eb0 100644 --- a/pkg/config/stepmeta.go +++ b/pkg/config/stepmeta.go @@ -47,6 +47,13 @@ type StepParameters struct { Type string `json:"type"` Mandatory bool `json:"mandatory,omitempty"` Default interface{} `json:"default,omitempty"` + Aliases []Alias `json:"aliases,omitempty"` +} + +// Alias defines a step input parameter alias +type Alias struct { + Name string `json:"name,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` } // StepResources defines the resources to be provided by the step context, e.g. Jenkins pipeline @@ -164,7 +171,7 @@ func (m *StepData) GetContextParameterFilters() StepFilters { return filters } -// GetContextDefaults retrieves context defaults like container image, name, env vars, ... +// GetContextDefaults retrieves context defaults like container image, name, env vars, resources, ... // It only supports scenarios with one container and optionally one sidecar func (m *StepData) GetContextDefaults(stepName string) (io.ReadCloser, error) { @@ -208,6 +215,16 @@ func (m *StepData) GetContextDefaults(stepName string) (io.ReadCloser, error) { //p["sidecarOptions"] = m.Spec.Sidecars[0]. //p["sidecarVolumeBind"] = m.Spec.Sidecars[0]. + if len(m.Spec.Inputs.Resources) > 0 { + var resources []string + for _, resource := range m.Spec.Inputs.Resources { + if resource.Type == "stash" { + resources = append(resources, resource.Name) + } + } + p["stashContent"] = resources + } + c := Config{ Steps: map[string]map[string]interface{}{ stepName: p, diff --git a/pkg/config/stepmeta_test.go b/pkg/config/stepmeta_test.go index b08d04a9f..f5ba27fa2 100644 --- a/pkg/config/stepmeta_test.go +++ b/pkg/config/stepmeta_test.go @@ -282,6 +282,22 @@ func TestGetContextDefaults(t *testing.T) { t.Run("Positive case", func(t *testing.T) { metadata := StepData{ Spec: StepSpec{ + Inputs: StepInputs{ + Resources: []StepResources{ + { + Name: "buildDescriptor", + Type: "stash", + }, + { + Name: "source", + Type: "stash", + }, + { + Name: "test", + Type: "nonce", + }, + }, + }, Containers: []Container{ { Command: []string{"test/command"}, @@ -323,6 +339,7 @@ func TestGetContextDefaults(t *testing.T) { var d PipelineDefaults d.ReadPipelineDefaults([]io.ReadCloser{cd}) + assert.Equal(t, []interface{}{"buildDescriptor", "source"}, d.Defaults[0].Steps["testStep"]["stashContent"], "stashContent default not available") assert.Equal(t, "test/command", d.Defaults[0].Steps["testStep"]["containerCommand"], "containerCommand default not available") assert.Equal(t, "testcontainer", d.Defaults[0].Steps["testStep"]["containerName"], "containerName default not available") assert.Equal(t, "/bin/bash", d.Defaults[0].Steps["testStep"]["containerShell"], "containerShell default not available") From a5548ab443f8cdc6ddd0a61aff1db2aafe00df04 Mon Sep 17 00:00:00 2001 From: Maximilian Lenkeit Date: Wed, 30 Oct 2019 08:21:34 +0100 Subject: [PATCH 090/141] Add UI5 artefacts to default stash configuration (#896) * consider ui5.yaml as build descriptor * add common ui5 artefacts to tests stash --- resources/default_pipeline_environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 4b6af4620..f8b0c7b64 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -440,14 +440,14 @@ steps: noDefaultExludes: [] pipelineStashFilesBeforeBuild: stashIncludes: - buildDescriptor: '**/pom.xml, **/.mvn/**, **/assembly.xml, **/.swagger-codegen-ignore, **/package.json, **/requirements.txt, **/setup.py, **/mta*.y*ml, **/.npmrc, Dockerfile, .hadolint.yaml, **/VERSION, **/version.txt, **/Gopkg.*, **/dub.json, **/dub.sdl, **/build.sbt, **/sbtDescriptor.json, **/project/*' + buildDescriptor: '**/pom.xml, **/.mvn/**, **/assembly.xml, **/.swagger-codegen-ignore, **/package.json, **/requirements.txt, **/setup.py, **/mta*.y*ml, **/.npmrc, Dockerfile, .hadolint.yaml, **/VERSION, **/version.txt, **/Gopkg.*, **/dub.json, **/dub.sdl, **/build.sbt, **/sbtDescriptor.json, **/project/*, **/ui5.yaml, **/ui5.yml' deployDescriptor: '**/manifest*.y*ml, **/*.mtaext.y*ml, **/*.mtaext, **/xs-app.json, helm/**, *.y*ml' git: '.git/**' opa5: '**/*.*' opensourceConfiguration: '**/srcclr.yml, **/vulas-custom.properties, **/.nsprc, **/.retireignore, **/.retireignore.json, **/.snyk, **/wss-unified-agent.config, **/vendor/**/*' pipelineConfigAndTests: '.pipeline/**' securityDescriptor: '**/xs-security.json' - tests: '**/pom.xml, **/*.json, **/*.xml, **/src/**, **/node_modules/**, **/specs/**, **/env/**, **/*.js, **/tests/**' + tests: '**/pom.xml, **/*.json, **/*.xml, **/src/**, **/node_modules/**, **/specs/**, **/env/**, **/*.js, **/tests/**, **/*.html, **/*.css, **/*.properties' stashExcludes: buildDescriptor: '**/node_modules/**/package.json' deployDescriptor: '' From b6884832baa9cfdfe776e4ced51993a539c98bb5 Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Wed, 30 Oct 2019 09:20:25 +0100 Subject: [PATCH 091/141] Add karma command --- cmd/piper.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/piper.go b/cmd/piper.go index b868a5ecc..70eb63430 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -39,6 +39,7 @@ var generalConfig generalConfigOptions func Execute() { rootCmd.AddCommand(ConfigCommand()) + rootCmd.AddCommand(KarmaExecuteTestsCommand()) addRootFlags(rootCmd) if err := rootCmd.Execute(); err != nil { From 74dd26383441593f7c6258b7893e18e045dd7afb Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 30 Oct 2019 09:52:41 +0100 Subject: [PATCH 092/141] Prepare testing command/shell executions (#930) * Move shell call related mocks to piper_test.go --- cmd/interfaces.go | 5 +++++ cmd/karmaExecuteTests_test.go | 30 +++------------------------ cmd/piper_test.go | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/cmd/interfaces.go b/cmd/interfaces.go index fb0515ab8..b6c1f196a 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -4,3 +4,8 @@ type execRunner interface { RunExecutable(e string, p ...string) error Dir(d string) } + +type shellRunner interface { + RunShell(s string, c string) error + Dir(d string) +} diff --git a/cmd/karmaExecuteTests_test.go b/cmd/karmaExecuteTests_test.go index 578de4721..7568c5f85 100644 --- a/cmd/karmaExecuteTests_test.go +++ b/cmd/karmaExecuteTests_test.go @@ -1,40 +1,16 @@ package cmd import ( - "fmt" "testing" "github.com/stretchr/testify/assert" ) -type mockRunner struct { - dir []string - calls []execCall -} - -type execCall struct { - exec string - params []string -} - -func (m *mockRunner) Dir(d string) { - m.dir = append(m.dir, d) -} - -func (m *mockRunner) RunExecutable(e string, p ...string) error { - if e == "fail" { - return fmt.Errorf("error case") - } - exec := execCall{exec: e, params: p} - m.calls = append(m.calls, exec) - return nil -} - func TestRunKarma(t *testing.T) { t.Run("success case", func(t *testing.T) { opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "npm run test"} - e := mockRunner{} + e := execMockRunner{} err := runKarma(opts, &e) assert.NoError(t, err, "error occured but no error expected") @@ -50,7 +26,7 @@ func TestRunKarma(t *testing.T) { t.Run("error case install command", func(t *testing.T) { opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "fail install test", RunCommand: "npm run test"} - e := mockRunner{} + e := execMockRunner{} err := runKarma(opts, &e) assert.Error(t, err, "error expected but none occcured") }) @@ -58,7 +34,7 @@ func TestRunKarma(t *testing.T) { t.Run("error case run command", func(t *testing.T) { opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "fail run test"} - e := mockRunner{} + e := execMockRunner{} err := runKarma(opts, &e) assert.Error(t, err, "error expected but none occcured") }) diff --git a/cmd/piper_test.go b/cmd/piper_test.go index f7eaed902..c824d9a67 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "io/ioutil" "strings" @@ -12,6 +13,44 @@ import ( "github.com/stretchr/testify/assert" ) + +type execMockRunner struct { + dir []string + calls []execCall +} + +type execCall struct { + exec string + params []string +} + +type shellMockRunner struct { + dir string + calls []string +} + +func (m *execMockRunner) Dir(d string) { + m.dir = append(m.dir, d) +} + +func (m *execMockRunner) RunExecutable(e string, p ...string) error { + if e == "fail" { + return fmt.Errorf("error case") + } + exec := execCall{exec: e, params: p} + m.calls = append(m.calls, exec) + return nil +} + +func(m *shellMockRunner) Dir(d string) { + m.dir = d +} + +func(m *shellMockRunner) RunShell(s string, c string) error { + m.calls = append(m.calls, c) + return nil +} + type stepOptions struct { TestParam string `json:"testParam,omitempty"` } From 101ccaf7f6cc0647f32d434511dc081124f2b047 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 30 Oct 2019 10:08:41 +0100 Subject: [PATCH 093/141] Add karma command (#937) --- cmd/piper.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/piper.go b/cmd/piper.go index b868a5ecc..70eb63430 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -39,6 +39,7 @@ var generalConfig generalConfigOptions func Execute() { rootCmd.AddCommand(ConfigCommand()) + rootCmd.AddCommand(KarmaExecuteTestsCommand()) addRootFlags(rootCmd) if err := rootCmd.Execute(); err != nil { From 3a128001f2b063368e9076668da2fc26aff633f9 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 30 Oct 2019 21:47:48 +0100 Subject: [PATCH 094/141] Cleanup: remove commented line (leftover ?) (#939) --- pkg/command/command_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/command/command_test.go b/pkg/command/command_test.go index beb231531..c23f034f4 100644 --- a/pkg/command/command_test.go +++ b/pkg/command/command_test.go @@ -114,7 +114,6 @@ func TestPrepareOut(t *testing.T) { } func TestCmdPipes(t *testing.T) { - //cmd := helperCommand(t, "echo", "foo bar", "baz") cmd := helperCommand("echo", "foo bar", "baz") defer func() { ExecCommand = exec.Command }() From 5b2d3a1663e8440893349e37008f5f97a74558d5 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 31 Oct 2019 13:57:29 +0100 Subject: [PATCH 095/141] Version command for piper go (#922) --- Dockerfile | 2 ++ cmd/piper.go | 1 + cmd/version.go | 28 +++++++++++++++ cmd/version_generated.go | 49 ++++++++++++++++++++++++++ cmd/version_generated_test.go | 16 +++++++++ cmd/version_test.go | 61 +++++++++++++++++++++++++++++++++ resources/metadata/version.yaml | 5 +++ 7 files changed, 162 insertions(+) create mode 100644 cmd/version.go create mode 100644 cmd/version_generated.go create mode 100644 cmd/version_generated_test.go create mode 100644 cmd/version_test.go create mode 100644 resources/metadata/version.yaml diff --git a/Dockerfile b/Dockerfile index a5a2eebf5..bd7f989eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ RUN go test ./... -cover ## ONLY tests so far, building to be added later # execute build # RUN go build -o piper +RUN export GIT_COMMIT=$(git rev-parse HEAD) && \ + go build -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT}" -o piper # FROM gcr.io/distroless/base:latest # COPY --from=build-env /build/piper /piper diff --git a/cmd/piper.go b/cmd/piper.go index 70eb63430..3813bb54b 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -39,6 +39,7 @@ var generalConfig generalConfigOptions func Execute() { rootCmd.AddCommand(ConfigCommand()) + rootCmd.AddCommand(VersionCommand()) rootCmd.AddCommand(KarmaExecuteTestsCommand()) addRootFlags(rootCmd) diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 000000000..15191735d --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" +) + +// GitCommit ... +var GitCommit string + +// GitTag ... +var GitTag string + +func version(myVersionOptions versionOptions) error { + + gitCommit, gitTag := "", "" + + if len(GitCommit) > 0 { + gitCommit = GitCommit + } + + if len(GitTag) > 0 { + gitTag = GitTag + } + + _, err := fmt.Printf("piper-version:\n commit: \"%s\"\n tag: \"%s\"\n", gitCommit, gitTag) + + return err +} diff --git a/cmd/version_generated.go b/cmd/version_generated.go new file mode 100644 index 000000000..746dc3896 --- /dev/null +++ b/cmd/version_generated.go @@ -0,0 +1,49 @@ +package cmd + +import ( + //"os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" +) + +type versionOptions struct { +} + +var myVersionOptions versionOptions +var versionStepConfigJSON string + +// VersionCommand Returns the version of the piper binary +func VersionCommand() *cobra.Command { + metadata := versionMetadata() + var createVersionCmd = &cobra.Command{ + Use: "version", + Short: "Returns the version of the piper binary", + Long: `Writes the commit hash and the tag (if any) to stdout and exits with 0.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return PrepareConfig(cmd, &metadata, "version", &myVersionOptions, openPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return version(myVersionOptions) + }, + } + + addVersionFlags(createVersionCmd) + return createVersionCmd +} + +func addVersionFlags(cmd *cobra.Command) { + +} + +// retrieve step metadata +func versionMetadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{}, + }, + }, + } + return theMetaData +} diff --git a/cmd/version_generated_test.go b/cmd/version_generated_test.go new file mode 100644 index 000000000..f3185e2cd --- /dev/null +++ b/cmd/version_generated_test.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVersionCommand(t *testing.T) { + + testCmd := VersionCommand() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "version", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 000000000..5135bb4b5 --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "testing" + "os" + "bytes" + "io" + "github.com/stretchr/testify/assert" +) + +func TestVersion(t *testing.T) { + + t.Run("versionAndTagInitialValues", func(t *testing.T) { + + result := runVersionCommand(t, "", "") + assert.Contains(t, result, "commit: \"\"") + assert.Contains(t, result, "tag: \"\"") + }) + + + t.Run("versionAndTagSet", func(t *testing.T) { + + result := runVersionCommand(t, "16bafe", "v1.2.3") + assert.Contains(t, result, "commit: \"16bafe\"") + assert.Contains(t, result, "tag: \"v1.2.3\"") + }) +} + +func runVersionCommand(t *testing.T, commitID, tag string) string { + + orig := os.Stdout + defer func() {os.Stdout = orig}() + + r,w,e := os.Pipe() + if e != nil { + t.Error("Cannot setup pipes.") + } + + os.Stdout = w + + // + // needs to be set in the free wild by the build process: + // go build -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT} -X github.com/SAP/jenkins-library/cmd.GitTag=${GIT_TAG}" + if len(commitID) > 0 { GitCommit = commitID; } + if len(tag) > 0 { GitTag = tag } + defer func() { GitCommit = ""; GitTag = "" }() + // + // + + var myVersionOptions versionOptions + e = version(myVersionOptions) + if e != nil { + t.Error("Version command failed.") + } + + w.Close() + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} \ No newline at end of file diff --git a/resources/metadata/version.yaml b/resources/metadata/version.yaml new file mode 100644 index 000000000..32b519755 --- /dev/null +++ b/resources/metadata/version.yaml @@ -0,0 +1,5 @@ +metadata: + name: version + description: Returns the version of the piper binary + longDescription: | + Writes the commit hash and the tag (if any) to stdout and exits with 0. From 31d8ddb719102ad3e1336dce2723b9561981bb71 Mon Sep 17 00:00:00 2001 From: Stengel Date: Mon, 4 Nov 2019 13:46:30 +0100 Subject: [PATCH 096/141] Map with single parameter --- vars/cfManifestSubstituteVariables.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vars/cfManifestSubstituteVariables.groovy b/vars/cfManifestSubstituteVariables.groovy index 50d7c14b1..5da646e09 100644 --- a/vars/cfManifestSubstituteVariables.groovy +++ b/vars/cfManifestSubstituteVariables.groovy @@ -161,7 +161,7 @@ private Object substitute(String manifestFilePath, List manifestVariable def manifestData = loadManifestData(manifestFilePath, debugHelper) // replace variables from list first. - List> reversedManifestVariablesList = manifestVariablesList.reverse() // to make sure last one wins. + List> reversedManifestVariablesList = manifestVariablesList.reverse() // to make sure last one wins. def result = manifestData for (Map manifestVariableData : reversedManifestVariablesList) { From 742a67fc607a74975f1e86958a71f804bf653bfb Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Mon, 4 Nov 2019 14:43:33 +0100 Subject: [PATCH 097/141] Add GO logging with logrus (#938) * add log package * add logrus dependency * add logging to karma step * add log stepName to generator, respect verbose flag --- cmd/karmaExecuteTests.go | 23 ++++++++++----- cmd/karmaExecuteTests_generated.go | 3 ++ cmd/karmaExecuteTests_test.go | 19 ++++++++----- cmd/version_generated.go | 3 ++ go.mod | 1 + go.sum | 6 ++++ pkg/generator/step-metadata.go | 3 ++ .../step_code_generated.golden | 3 ++ pkg/log/log.go | 28 +++++++++++++++++++ 9 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 pkg/log/log.go diff --git a/cmd/karmaExecuteTests.go b/cmd/karmaExecuteTests.go index 16680b8ac..9d7bfb9ef 100644 --- a/cmd/karmaExecuteTests.go +++ b/cmd/karmaExecuteTests.go @@ -4,30 +4,39 @@ import ( "strings" "github.com/SAP/jenkins-library/pkg/command" - "github.com/pkg/errors" + "github.com/SAP/jenkins-library/pkg/log" ) func karmaExecuteTests(myKarmaExecuteTestsOptions karmaExecuteTestsOptions) error { c := command.Command{} - return runKarma(myKarmaExecuteTestsOptions, &c) + // reroute command output to loging framework + // also log stdout as Karma reports into it + c.Stdout = log.Entry().Writer() + c.Stderr = log.Entry().Writer() + runKarma(myKarmaExecuteTestsOptions, &c) + return nil } -func runKarma(myKarmaExecuteTestsOptions karmaExecuteTestsOptions, command execRunner) error { +func runKarma(myKarmaExecuteTestsOptions karmaExecuteTestsOptions, command execRunner) { installCommandTokens := tokenize(myKarmaExecuteTestsOptions.InstallCommand) command.Dir(myKarmaExecuteTestsOptions.ModulePath) err := command.RunExecutable(installCommandTokens[0], installCommandTokens[1:]...) if err != nil { - return errors.Wrapf(err, "failed to execute install command '%v'", myKarmaExecuteTestsOptions.InstallCommand) + log.Entry(). + WithError(err). + WithField("command", myKarmaExecuteTestsOptions.InstallCommand). + Fatal("failed to execute install command") } runCommandTokens := tokenize(myKarmaExecuteTestsOptions.RunCommand) command.Dir(myKarmaExecuteTestsOptions.ModulePath) err = command.RunExecutable(runCommandTokens[0], runCommandTokens[1:]...) if err != nil { - return errors.Wrapf(err, "failed to execute run command '%v'", myKarmaExecuteTestsOptions.RunCommand) + log.Entry(). + WithError(err). + WithField("command", myKarmaExecuteTestsOptions.RunCommand). + Fatal("failed to execute run command") } - - return nil } func tokenize(command string) []string { diff --git a/cmd/karmaExecuteTests_generated.go b/cmd/karmaExecuteTests_generated.go index 5b331d126..3d6b58925 100644 --- a/cmd/karmaExecuteTests_generated.go +++ b/cmd/karmaExecuteTests_generated.go @@ -4,6 +4,7 @@ import ( //"os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" ) @@ -34,6 +35,8 @@ In the Docker network, the containers can be referenced by the values provided i !!! note In a Kubernetes environment, the containers both need to be referenced with ` + "`" + `localhost` + "`" + `.`, PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("karmaExecuteTests") + log.SetVerbose(generalConfig.verbose) return PrepareConfig(cmd, &metadata, "karmaExecuteTests", &myKarmaExecuteTestsOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/karmaExecuteTests_test.go b/cmd/karmaExecuteTests_test.go index 7568c5f85..057e8c625 100644 --- a/cmd/karmaExecuteTests_test.go +++ b/cmd/karmaExecuteTests_test.go @@ -3,6 +3,7 @@ package cmd import ( "testing" + "github.com/SAP/jenkins-library/pkg/log" "github.com/stretchr/testify/assert" ) @@ -11,9 +12,7 @@ func TestRunKarma(t *testing.T) { opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "npm run test"} e := execMockRunner{} - err := runKarma(opts, &e) - - assert.NoError(t, err, "error occured but no error expected") + runKarma(opts, &e) assert.Equal(t, e.dir[0], "./test", "install command dir incorrect") assert.Equal(t, e.calls[0], execCall{exec: "npm", params: []string{"install", "test"}}, "install command/params incorrect") @@ -24,18 +23,24 @@ func TestRunKarma(t *testing.T) { }) t.Run("error case install command", func(t *testing.T) { + var hasFailed bool + log.Entry().Logger.ExitFunc = func(int) { hasFailed = true } + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "fail install test", RunCommand: "npm run test"} e := execMockRunner{} - err := runKarma(opts, &e) - assert.Error(t, err, "error expected but none occcured") + runKarma(opts, &e) + assert.True(t, hasFailed, "expected command to exit with fatal") }) t.Run("error case run command", func(t *testing.T) { + var hasFailed bool + log.Entry().Logger.ExitFunc = func(int) { hasFailed = true } + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "fail run test"} e := execMockRunner{} - err := runKarma(opts, &e) - assert.Error(t, err, "error expected but none occcured") + runKarma(opts, &e) + assert.True(t, hasFailed, "expected command to exit with fatal") }) } diff --git a/cmd/version_generated.go b/cmd/version_generated.go index 746dc3896..2b4802299 100644 --- a/cmd/version_generated.go +++ b/cmd/version_generated.go @@ -4,6 +4,7 @@ import ( //"os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" ) @@ -21,6 +22,8 @@ func VersionCommand() *cobra.Command { Short: "Returns the version of the piper binary", Long: `Writes the commit hash and the tag (if any) to stdout and exits with 0.`, PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("version") + log.SetVerbose(generalConfig.verbose) return PrepareConfig(cmd, &metadata, "version", &myVersionOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index 2c87f65d7..fd9f9a4ec 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/ghodss/yaml v1.0.0 github.com/pkg/errors v0.8.1 + github.com/sirupsen/logrus v1.4.2 github.com/spf13/cobra v0.0.5 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.2.2 diff --git a/go.sum b/go.sum index 796e8ee0a..138315660 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -21,6 +22,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= @@ -30,12 +33,15 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 299e679c7..4809a58d6 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -33,6 +33,7 @@ import ( //"os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" ) @@ -52,6 +53,8 @@ func {{.CobraCmdFuncName}}() *cobra.Command { Short: "{{.Short}}", Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("{{ .StepName }}") + log.SetVerbose(generalConfig.verbose) return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden index 5aea334dd..defa93201 100644 --- a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -4,6 +4,7 @@ import ( //"os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" ) @@ -24,6 +25,8 @@ func TestStepCommand() *cobra.Command { Short: "Test description", Long: `Long Test description`, PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("testStep") + log.SetVerbose(generalConfig.verbose) return PrepareConfig(cmd, &metadata, "testStep", &myTestStepOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 000000000..1cd7eeeb3 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,28 @@ +package log + +import ( + "github.com/sirupsen/logrus" +) + +var logger *logrus.Entry + +// Entry returns the logger entry or creates one if none is present. +func Entry() *logrus.Entry { + if logger == nil { + logger = logrus.WithField("library", "sap/jenkins-library") + } + return logger +} + +// SetVerbose sets the log level with respect to verbose flag. +func SetVerbose(verbose bool) { + if verbose { + //Logger().Debugf("logging set to level: %s", level) + logrus.SetLevel(logrus.DebugLevel) + } +} + +// SetStepName sets the stepName field. +func SetStepName(stepName string) { + logger = Entry().WithField("stepName", stepName) +} From 6256a0b9aa4e5d28fecaa601b49b2f707c39e9ee Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Mon, 4 Nov 2019 16:07:30 +0100 Subject: [PATCH 098/141] Update githubPublishRelease --- cmd/githubPublishRelease.go | 148 +++++++++++++++ cmd/githubPublishRelease_generated.go | 177 ++++++++++++++++++ cmd/githubPublishRelease_generated_test.go | 16 ++ cmd/githubPublishRelease_test.go | 38 ++++ cmd/karmaExecuteTests_generated.go | 2 - cmd/piper.go | 1 + cmd/piper_test.go | 5 +- go.mod | 2 + go.sum | 21 +++ pkg/generator/step-metadata.go | 20 +- pkg/generator/step-metadata_test.go | 9 +- .../step_code_generated.golden | 2 +- pkg/github/github.go | 23 +++ resources/metadata/githubrelease.yaml | 132 +++++++++++++ 14 files changed, 579 insertions(+), 17 deletions(-) create mode 100644 cmd/githubPublishRelease.go create mode 100644 cmd/githubPublishRelease_generated.go create mode 100644 cmd/githubPublishRelease_generated_test.go create mode 100644 cmd/githubPublishRelease_test.go create mode 100644 pkg/github/github.go create mode 100644 resources/metadata/githubrelease.yaml diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go new file mode 100644 index 000000000..aa6044303 --- /dev/null +++ b/cmd/githubPublishRelease.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v28/github" + "github.com/pkg/errors" + + piperGithub "github.com/SAP/jenkins-library/pkg/github" +) + +type githubRepoClient interface { + GetLatestRelease(ctx context.Context, owner string, repo string) (*github.RepositoryRelease, *github.Response, error) + CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) +} + +type githubIssueClient interface { + ListByRepo(ctx context.Context, owner string, repo string, opt *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) +} + +func githubPublishRelease(myGithubPublishReleaseOptions githubPublishReleaseOptions) error { + ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.GithubToken, myGithubPublishReleaseOptions.GithubAPIURL, myGithubPublishReleaseOptions.GithubAPIURL) + if err != nil { + return err + } + + err = runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, client.Repositories, client.Issues) + if err != nil { + return err + } + + return nil +} + +func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghRepoClient githubRepoClient, ghIssueClient githubIssueClient) error { + + var publishedAt github.Timestamp + lastRelease, resp, err := ghRepoClient.GetLatestRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) + if err != nil { + if resp.StatusCode == 404 { + //first release + myGithubPublishReleaseOptions.AddDeltaToLastRelease = false + publishedAt = lastRelease.GetPublishedAt() + } else { + return errors.Wrap(err, "Error occured when retrieving latest GitHub releass") + } + } + + releaseBody := "" + + if len(myGithubPublishReleaseOptions.ReleaseBodyHeader) > 0 { + releaseBody += myGithubPublishReleaseOptions.ReleaseBodyHeader + "
" + } + + if myGithubPublishReleaseOptions.AddClosedIssues { + releaseBody += getClosedIssuesText(ctx, publishedAt, myGithubPublishReleaseOptions, ghIssueClient) + } + + if myGithubPublishReleaseOptions.AddDeltaToLastRelease { + releaseBody += getReleaseDeltaText(myGithubPublishReleaseOptions, lastRelease) + } + + release := github.RepositoryRelease{ + TagName: &myGithubPublishReleaseOptions.Version, + TargetCommitish: &myGithubPublishReleaseOptions.Commitish, + Name: &myGithubPublishReleaseOptions.Version, + Body: &releaseBody, + } + + //create release + createdRelease, _, err := ghRepoClient.CreateRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &release) + if err != nil { + return errors.Wrapf(err, "creation of release '%v' failed", release.TagName) + } + + // todo switch to logging + fmt.Printf("Release %v created on %v/%v", *createdRelease.TagName, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) + + return nil +} + +func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghIssueClient githubIssueClient) string { + closedIssuesText := "" + options := github.IssueListByRepoOptions{ + State: "closed", + Direction: "asc", + Since: publishedAt.Time, + } + if len(myGithubPublishReleaseOptions.Labels) > 0 { + options.Labels = myGithubPublishReleaseOptions.Labels + } + ghIssues, _, err := ghIssueClient.ListByRepo(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &options) + if err != nil { + //log error + } + + prTexts := []string{"
**List of closed pull-requests since last release**"} + issueTexts := []string{"
**List of closed issues since last release**"} + + for _, issue := range ghIssues { + if issue.IsPullRequest() && !isExcluded(issue, myGithubPublishReleaseOptions.ExcludeLabels) { + prTexts = append(prTexts, fmt.Sprintf("[#%v](%v): %v", issue.GetNumber(), issue.GetHTMLURL(), issue.GetTitle())) + } else if !issue.IsPullRequest() && !isExcluded(issue, myGithubPublishReleaseOptions.ExcludeLabels) { + issueTexts = append(issueTexts, fmt.Sprintf("[#%v](%v): %v", issue.GetNumber(), issue.GetHTMLURL(), issue.GetTitle())) + } + } + + if len(prTexts) > 1 { + closedIssuesText += strings.Join(prTexts, "\n") + "\n" + } + + if len(issueTexts) > 1 { + closedIssuesText += strings.Join(issueTexts, "\n") + "\n" + } + return closedIssuesText +} + +func getReleaseDeltaText(myGithubPublishReleaseOptions *githubPublishReleaseOptions, lastRelease *github.RepositoryRelease) string { + releaseDeltaText := "" + + //add delta link to previous release + releaseDeltaText += "
**Changes**
" + releaseDeltaText += fmt.Sprintf( + "[%v...%v](%v/%v/%v/compare/%v...%v)
", + lastRelease.GetTagName(), + myGithubPublishReleaseOptions.Version, + myGithubPublishReleaseOptions.GithubServerURL, + myGithubPublishReleaseOptions.GithubOrg, + myGithubPublishReleaseOptions.GithubRepo, + lastRelease.GetTagName(), myGithubPublishReleaseOptions.Version, + ) + + return releaseDeltaText +} + +func isExcluded(issue *github.Issue, excludeLabels []string) bool { + //issue.Labels[0].GetName() + for _, ex := range excludeLabels { + for _, l := range issue.Labels { + if ex == l.GetName() { + return true + } + } + } + return false +} diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go new file mode 100644 index 000000000..10cc6e2aa --- /dev/null +++ b/cmd/githubPublishRelease_generated.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "os" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/spf13/cobra" +) + +type githubPublishReleaseOptions struct { + AddClosedIssues bool `json:"addClosedIssues,omitempty"` + AddDeltaToLastRelease bool `json:"addDeltaToLastRelease,omitempty"` + AssetPath string `json:"assetPath,omitempty"` + Commitish string `json:"commitish,omitempty"` + ExcludeLabels []string `json:"excludeLabels,omitempty"` + GithubAPIURL string `json:"githubApiUrl,omitempty"` + GithubOrg string `json:"githubOrg,omitempty"` + GithubRepo string `json:"githubRepo,omitempty"` + GithubServerURL string `json:"githubServerUrl,omitempty"` + GithubToken string `json:"githubToken,omitempty"` + Labels []string `json:"labels,omitempty"` + ReleaseBodyHeader string `json:"releaseBodyHeader,omitempty"` + Update bool `json:"update,omitempty"` + Version string `json:"version,omitempty"` +} + +var myGithubPublishReleaseOptions githubPublishReleaseOptions +var githubPublishReleaseStepConfigJSON string + +// GithubPublishReleaseCommand Publish a release in GitHub +func GithubPublishReleaseCommand() *cobra.Command { + metadata := githubPublishReleaseMetadata() + var createGithubPublishReleaseCmd = &cobra.Command{ + Use: "githubPublishRelease", + Short: "Publish a release in GitHub", + Long: `This step creates a tag in your GitHub repository together with a release. +The release can be filled with text plus additional information like: + +* Closed pull request since last release +* Closed issues since last release +* Link to delta information showing all commits since last release + +The result looks like + +![Example release](../images/githubRelease.png)`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return PrepareConfig(cmd, &metadata, "githubPublishRelease", &myGithubPublishReleaseOptions, openPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return githubPublishRelease(myGithubPublishReleaseOptions) + }, + } + + addGithubPublishReleaseFlags(createGithubPublishReleaseCmd) + return createGithubPublishReleaseCmd +} + +func addGithubPublishReleaseFlags(cmd *cobra.Command) { + cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.AddClosedIssues, "addClosedIssues", false, "If set to `true`, closed issues and merged pull-requests since the last release will added below the `releaseBodyHeader`") + cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.AddDeltaToLastRelease, "addDeltaToLastRelease", false, "If set to `true`, a link will be added to the relese information that brings up all commits since the last release.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.AssetPath, "assetPath", os.Getenv("PIPER_assetPath"), "Path to a release asset which should be uploaded to the list of release assets.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Commitish, "commitish", "master", "Target git commitish for the release") + cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.ExcludeLabels, "excludeLabels", []string{}, "Allows to exclude issues with dedicated list of labels.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubAPIURL, "githubApiUrl", "https://api.github.com", "Set the GitHub API url.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubOrg, "githubOrg", os.Getenv("PIPER_githubOrg"), "Set the GitHub organization.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubRepo, "githubRepo", os.Getenv("PIPER_githubRepo"), "Set the GitHub repository.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubServerURL, "githubServerUrl", "https://github.com", "GitHub server url for end-user access.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubToken, "githubToken", os.Getenv("PIPER_githubToken"), "GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line") + cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.Labels, "labels", []string{}, "Labels to include in issue search.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ReleaseBodyHeader, "releaseBodyHeader", os.Getenv("PIPER_releaseBodyHeader"), "Content which will appear for the release.") + cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.Update, "update", false, "Specify if the release should be updated in case it already exists") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Version, "version", os.Getenv("PIPER_version"), "Define the version number which will be written as tag as well as release name.") + + cmd.MarkFlagRequired("githubApiUrl") + cmd.MarkFlagRequired("githubOrg") + cmd.MarkFlagRequired("githubRepo") + cmd.MarkFlagRequired("githubServerUrl") + cmd.MarkFlagRequired("githubToken") + cmd.MarkFlagRequired("version") +} + +// retrieve step metadata +func githubPublishReleaseMetadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + { + Name: "addClosedIssues", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "bool", + Mandatory: false, + }, + { + Name: "addDeltaToLastRelease", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "bool", + Mandatory: false, + }, + { + Name: "assetPath", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + }, + { + Name: "commitish", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + }, + { + Name: "excludeLabels", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "[]string", + Mandatory: false, + }, + { + Name: "githubApiUrl", + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "githubOrg", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "githubRepo", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "githubServerUrl", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "githubToken", + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + { + Name: "labels", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "[]string", + Mandatory: false, + }, + { + Name: "releaseBodyHeader", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + }, + { + Name: "update", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "bool", + Mandatory: false, + }, + { + Name: "version", + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, + }, + }, + }, + } + return theMetaData +} diff --git a/cmd/githubPublishRelease_generated_test.go b/cmd/githubPublishRelease_generated_test.go new file mode 100644 index 000000000..a17000345 --- /dev/null +++ b/cmd/githubPublishRelease_generated_test.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGithubPublishReleaseCommand(t *testing.T) { + + testCmd := GithubPublishReleaseCommand() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "githubPublishRelease", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/githubPublishRelease_test.go b/cmd/githubPublishRelease_test.go new file mode 100644 index 000000000..33ff34fa3 --- /dev/null +++ b/cmd/githubPublishRelease_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/google/go-github/v28/github" + "github.com/stretchr/testify/assert" +) + +func TestRunGithubPublishRelease(t *testing.T) { + +} + +func TestIsExcluded(t *testing.T) { + + l1 := "label1" + l2 := "label2" + + tt := []struct { + issue *github.Issue + excludeLabels []string + expected bool + }{ + {issue: nil, excludeLabels: nil, expected: false}, + {issue: &github.Issue{}, excludeLabels: nil, expected: false}, + {issue: &github.Issue{Labels: []github.Label{{Name: &l1}}}, excludeLabels: nil, expected: false}, + {issue: &github.Issue{Labels: []github.Label{{Name: &l1}}}, excludeLabels: []string{"label0"}, expected: false}, + {issue: &github.Issue{Labels: []github.Label{{Name: &l1}}}, excludeLabels: []string{"label1"}, expected: true}, + {issue: &github.Issue{Labels: []github.Label{{Name: &l1}, {Name: &l2}}}, excludeLabels: []string{}, expected: false}, + {issue: &github.Issue{Labels: []github.Label{{Name: &l1}, {Name: &l2}}}, excludeLabels: []string{"label1"}, expected: true}, + } + + for k, v := range tt { + assert.Equal(t, v.expected, isExcluded(v.issue, v.excludeLabels), fmt.Sprintf("Run %v failed", k)) + } + +} diff --git a/cmd/karmaExecuteTests_generated.go b/cmd/karmaExecuteTests_generated.go index 5b331d126..919b98915 100644 --- a/cmd/karmaExecuteTests_generated.go +++ b/cmd/karmaExecuteTests_generated.go @@ -1,8 +1,6 @@ package cmd import ( - //"os" - "github.com/SAP/jenkins-library/pkg/config" "github.com/spf13/cobra" ) diff --git a/cmd/piper.go b/cmd/piper.go index 70eb63430..9eb2817cb 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -40,6 +40,7 @@ func Execute() { rootCmd.AddCommand(ConfigCommand()) rootCmd.AddCommand(KarmaExecuteTestsCommand()) + rootCmd.AddCommand(GithubPublishReleaseCommand()) addRootFlags(rootCmd) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/piper_test.go b/cmd/piper_test.go index c824d9a67..72bbbfce6 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" ) - type execMockRunner struct { dir []string calls []execCall @@ -42,11 +41,11 @@ func (m *execMockRunner) RunExecutable(e string, p ...string) error { return nil } -func(m *shellMockRunner) Dir(d string) { +func (m *shellMockRunner) Dir(d string) { m.dir = d } -func(m *shellMockRunner) RunShell(s string, c string) error { +func (m *shellMockRunner) RunShell(s string, c string) error { m.calls = append(m.calls, c) return nil } diff --git a/go.mod b/go.mod index 2c87f65d7..12d32a621 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.13 require ( github.com/ghodss/yaml v1.0.0 + github.com/google/go-github/v28 v28.1.1 github.com/pkg/errors v0.8.1 github.com/spf13/cobra v0.0.5 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.2.2 + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 ) diff --git a/go.sum b/go.sum index 796e8ee0a..075d71733 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -9,6 +10,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= +github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -35,8 +42,22 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 299e679c7..b170f5903 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -30,7 +30,7 @@ type stepInfo struct { const stepGoTemplate = `package cmd import ( - //"os" + {{if .OSImport}}"os"{{end}} "github.com/SAP/jenkins-library/pkg/config" "github.com/spf13/cobra" @@ -145,10 +145,11 @@ func processMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCl fmt.Printf("Step name: %v\n", stepData.Metadata.Name) - err = setDefaultParameters(&stepData) + osImport := false + osImport, err = setDefaultParameters(&stepData) checkError(err) - myStepInfo := getStepInfo(&stepData) + myStepInfo := getStepInfo(&stepData, osImport) step := stepTemplate(myStepInfo) err = writeFile(fmt.Sprintf("cmd/%v_generated.go", stepData.Metadata.Name), step, 0644) @@ -169,14 +170,16 @@ func fileWriter(filename string, data []byte, perm os.FileMode) error { return ioutil.WriteFile(filename, data, perm) } -func setDefaultParameters(stepData *config.StepData) error { +func setDefaultParameters(stepData *config.StepData) (bool, error) { //ToDo: custom function for default handling, support all relevant parameter types + osImportRequired := false for k, param := range stepData.Spec.Inputs.Parameters { if param.Default == nil { switch param.Type { case "string": param.Default = fmt.Sprintf("os.Getenv(\"PIPER_%v\")", param.Name) + osImportRequired = true case "bool": // ToDo: Check if default should be read from env param.Default = "false" @@ -184,7 +187,7 @@ func setDefaultParameters(stepData *config.StepData) error { // ToDo: Check if default should be read from env param.Default = "[]string{}" default: - return fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) } } else { switch param.Type { @@ -199,16 +202,16 @@ func setDefaultParameters(stepData *config.StepData) error { case "[]string": param.Default = fmt.Sprintf("[]string{\"%v\"}", strings.Join(param.Default.([]string), "\", \"")) default: - return fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) } } stepData.Spec.Inputs.Parameters[k] = param } - return nil + return osImportRequired, nil } -func getStepInfo(stepData *config.StepData) stepInfo { +func getStepInfo(stepData *config.StepData, osImport bool) stepInfo { return stepInfo{ StepName: stepData.Metadata.Name, CobraCmdFuncName: fmt.Sprintf("%vCommand", strings.Title(stepData.Metadata.Name)), @@ -217,6 +220,7 @@ func getStepInfo(stepData *config.StepData) stepInfo { Long: stepData.Metadata.LongDescription, Metadata: stepData.Spec.Inputs.Parameters, FlagsFunc: fmt.Sprintf("add%vFlags", strings.Title(stepData.Metadata.Name)), + OSImport: osImport, } } diff --git a/pkg/generator/step-metadata_test.go b/pkg/generator/step-metadata_test.go index 538c772f0..2af975ca7 100644 --- a/pkg/generator/step-metadata_test.go +++ b/pkg/generator/step-metadata_test.go @@ -112,10 +112,12 @@ func TestSetDefaultParameters(t *testing.T) { "[]string{}", } - err := setDefaultParameters(&stepData) + osImport, err := setDefaultParameters(&stepData) assert.NoError(t, err, "error occured but none expected") + assert.Equal(t, true, osImport, "import of os package required") + for k, v := range expected { assert.Equal(t, v, stepData.Spec.Inputs.Parameters[k].Default, fmt.Sprintf("default not correct for parameter %v", k)) } @@ -145,7 +147,7 @@ func TestSetDefaultParameters(t *testing.T) { } for k, v := range stepData { - err := setDefaultParameters(&v) + _, err := setDefaultParameters(&v) assert.Error(t, err, fmt.Sprintf("error expected but none occured for parameter %v", k)) } }) @@ -168,7 +170,7 @@ func TestGetStepInfo(t *testing.T) { }, } - myStepInfo := getStepInfo(&stepData) + myStepInfo := getStepInfo(&stepData, true) assert.Equal(t, "testStep", myStepInfo.StepName, "StepName incorrect") assert.Equal(t, "TestStepCommand", myStepInfo.CobraCmdFuncName, "CobraCmdFuncName incorrect") @@ -177,6 +179,7 @@ func TestGetStepInfo(t *testing.T) { assert.Equal(t, "Long Test description", myStepInfo.Long, "Long incorrect") assert.Equal(t, stepData.Spec.Inputs.Parameters, myStepInfo.Metadata, "Metadata incorrect") assert.Equal(t, "addTestStepFlags", myStepInfo.FlagsFunc, "FlagsFunc incorrect") + assert.Equal(t, "addTestStepFlags", myStepInfo.FlagsFunc, "FlagsFunc incorrect") } diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden index 5aea334dd..ac667f25c 100644 --- a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -1,7 +1,7 @@ package cmd import ( - //"os" + "os" "github.com/SAP/jenkins-library/pkg/config" "github.com/spf13/cobra" diff --git a/pkg/github/github.go b/pkg/github/github.go new file mode 100644 index 000000000..5897a999c --- /dev/null +++ b/pkg/github/github.go @@ -0,0 +1,23 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v28/github" + "golang.org/x/oauth2" +) + +//NewClient creates a new GitHub client using an OAuth token for authentication +func NewClient(token, apiURL, uploadURL string) (context.Context, *github.Client, error) { + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + client, err := github.NewEnterpriseClient(apiURL, uploadURL, tc) + if err != nil { + return ctx, nil, err + } + return ctx, client, nil +} diff --git a/resources/metadata/githubrelease.yaml b/resources/metadata/githubrelease.yaml new file mode 100644 index 000000000..07b0ae1ce --- /dev/null +++ b/resources/metadata/githubrelease.yaml @@ -0,0 +1,132 @@ +metadata: + name: githubPublishRelease + description: Publish a release in GitHub + longDescription: | + This step creates a tag in your GitHub repository together with a release. + The release can be filled with text plus additional information like: + + * Closed pull request since last release + * Closed issues since last release + * Link to delta information showing all commits since last release + + The result looks like + + ![Example release](../images/githubRelease.png) +spec: + inputs: + secrets: + - name: githubTokenCredentialsId + description: Jenkins 'Secret text' credentials ID containing token to authenticate to GitHub. + type: jenkins + params: + - name: addClosedIssues + description: 'If set to `true`, closed issues and merged pull-requests since the last release will added below the `releaseBodyHeader`' + scope: + - PARAMETERS + - STAGES + - STEPS + type: bool + default: false + - name: addDeltaToLastRelease + description: 'If set to `true`, a link will be added to the relese information that brings up all commits since the last release.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: bool + default: false + - name: assetPath + description: Path to a release asset which should be uploaded to the list of release assets. + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + - name: commitish + description: 'Target git commitish for the release' + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + default: "master" + - name: excludeLabels + description: 'Allows to exclude issues with dedicated list of labels.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: '[]string' + - name: githubApiUrl + description: Set the GitHub API url. + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + type: string + default: https://api.github.com + mandatory: true + - name: githubOrg + description: 'Set the GitHub organization.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + mandatory: true + - name: githubRepo + description: 'Set the GitHub repository.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + mandatory: true + - name: githubServerUrl + description: 'GitHub server url for end-user access.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + default: https://github.com + mandatory: true + - name: githubToken + description: 'GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line' + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + type: string + mandatory: true + - name: labels + description: 'Labels to include in issue search.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: '[]string' + - name: releaseBodyHeader + description: Content which will appear for the release. + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + - name: update + description: Specify if the release should be updated in case it already exists + scope: + - PARAMETERS + - STAGES + - STEPS + type: bool + - name: version + description: 'Define the version number which will be written as tag as well as release name.' + scope: + - PARAMETERS + - STAGES + - STEPS + type: string + mandatory: true From c241066e21dfbfd386dd71c0f947db124c70848d Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Mon, 4 Nov 2019 16:29:39 +0100 Subject: [PATCH 099/141] Add capability for hierarchical defaults in golang --- cmd/getConfig.go | 2 - pkg/config/stepmeta.go | 161 +++++++++++++++++++++++++----------- pkg/config/stepmeta_test.go | 59 +++++++++---- 3 files changed, 157 insertions(+), 65 deletions(-) diff --git a/cmd/getConfig.go b/cmd/getConfig.go index bbc4cc799..ec57dda1d 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -83,8 +83,6 @@ func generateConfig() error { return errors.Wrap(err, "getting step config failed") } - //ToDo: Check for mandatory parameters - myConfigJSON, _ := config.GetJSON(stepConfig.Config) fmt.Println(myConfigJSON) diff --git a/pkg/config/stepmeta.go b/pkg/config/stepmeta.go index 11d764eb0..778ed1169 100644 --- a/pkg/config/stepmeta.go +++ b/pkg/config/stepmeta.go @@ -48,6 +48,7 @@ type StepParameters struct { Mandatory bool `json:"mandatory,omitempty"` Default interface{} `json:"default,omitempty"` Aliases []Alias `json:"aliases,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` } // Alias defines a step input parameter alias @@ -58,9 +59,10 @@ type Alias struct { // StepResources defines the resources to be provided by the step context, e.g. Jenkins pipeline type StepResources struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` } // StepSecrets defines the secrets to be provided by the step context, e.g. Jenkins pipeline @@ -78,14 +80,15 @@ type StepSecrets struct { // Container defines an execution container type Container struct { //ToDo: check dockerOptions, dockerVolumeBind, containerPortMappings, sidecarOptions, sidecarVolumeBind - Command []string `json:"command"` - EnvVars []EnvVar `json:"env"` - Image string `json:"image"` - ImagePullPolicy string `json:"imagePullPolicy"` - Name string `json:"name"` - ReadyCommand string `json:"readyCommand"` - Shell string `json:"shell"` - WorkingDir string `json:"workingDir"` + Command []string `json:"command"` + EnvVars []EnvVar `json:"env"` + Image string `json:"image"` + ImagePullPolicy string `json:"imagePullPolicy"` + Name string `json:"name"` + ReadyCommand string `json:"readyCommand"` + Shell string `json:"shell"` + WorkingDir string `json:"workingDir"` + Conditions []Condition `json:"conditions,omitempty"` } // EnvVar defines an environment variable @@ -94,6 +97,18 @@ type EnvVar struct { Value string `json:"value"` } +// Condition defines an condition which decides when the parameter, resource or container is valid +type Condition struct { + ConditionRef string `json:"conditionRef"` + Params []Param `json:"params"` +} + +// Param defines the parameters serving as inputs to the condition +type Param struct { + Name string `json:"name"` + Value string `json:"value"` +} + // StepFilters defines the filter parameters for the different sections type StepFilters struct { All []string @@ -123,19 +138,25 @@ func (m *StepData) ReadPipelineStepData(metadata io.ReadCloser) error { func (m *StepData) GetParameterFilters() StepFilters { var filters StepFilters for _, param := range m.Spec.Inputs.Parameters { - filters.All = append(filters.All, param.Name) + parameterKeys := []string{param.Name} + for _, condition := range param.Conditions { + for _, dependentParam := range condition.Params { + parameterKeys = append(parameterKeys, dependentParam.Value) + } + } + filters.All = append(filters.All, parameterKeys...) for _, scope := range param.Scope { switch scope { case "GENERAL": - filters.General = append(filters.General, param.Name) + filters.General = append(filters.General, parameterKeys...) case "STEPS": - filters.Steps = append(filters.Steps, param.Name) + filters.Steps = append(filters.Steps, parameterKeys...) case "STAGES": - filters.Stages = append(filters.Stages, param.Name) + filters.Stages = append(filters.Stages, parameterKeys...) case "PARAMETERS": - filters.Parameters = append(filters.Parameters, param.Name) + filters.Parameters = append(filters.Parameters, parameterKeys...) case "ENV": - filters.Env = append(filters.Env, param.Name) + filters.Env = append(filters.Env, parameterKeys...) } } } @@ -156,7 +177,15 @@ func (m *StepData) GetContextParameterFilters() StepFilters { containerFilters := []string{} if len(m.Spec.Containers) > 0 { - containerFilters = append(containerFilters, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}...) + parameterKeys := []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"} + for _, container := range m.Spec.Containers { + for _, condition := range container.Conditions { + for _, dependentParam := range condition.Params { + parameterKeys = append(parameterKeys, dependentParam.Value) + } + } + } + containerFilters = append(containerFilters, parameterKeys...) } if len(m.Spec.Sidecars) > 0 { //ToDo: support fallback for "dockerName" configuration property -> via aliasing? @@ -175,59 +204,93 @@ func (m *StepData) GetContextParameterFilters() StepFilters { // It only supports scenarios with one container and optionally one sidecar func (m *StepData) GetContextDefaults(stepName string) (io.ReadCloser, error) { - p := map[string]interface{}{} - //ToDo error handling empty Containers/Sidecars //ToDo handle empty Command - + root := map[string]interface{}{} if len(m.Spec.Containers) > 0 { - if len(m.Spec.Containers[0].Command) > 0 { - p["containerCommand"] = m.Spec.Containers[0].Command[0] - } - p["containerName"] = m.Spec.Containers[0].Name - p["containerShell"] = m.Spec.Containers[0].Shell - p["dockerEnvVars"] = envVarsAsStringSlice(m.Spec.Containers[0].EnvVars) - p["dockerImage"] = m.Spec.Containers[0].Image - p["dockerName"] = m.Spec.Containers[0].Name - p["dockerPullImage"] = m.Spec.Containers[0].ImagePullPolicy != "Never" - p["dockerWorkspace"] = m.Spec.Containers[0].WorkingDir + for _, container := range m.Spec.Containers { + key := "" + if len(container.Conditions) > 0 { + key = container.Conditions[0].Params[0].Value + } + p := map[string]interface{}{} + if key != "" { + root[key] = p + } else { + p = root + } + if len(container.Command) > 0 { + p["containerCommand"] = container.Command[0] + } + p["containerName"] = container.Name + p["containerShell"] = container.Shell + p["dockerEnvVars"] = envVarsAsStringSlice(container.EnvVars) + p["dockerImage"] = container.Image + p["dockerName"] = container.Name + p["dockerPullImage"] = container.ImagePullPolicy != "Never" + p["dockerWorkspace"] = container.WorkingDir + + // Ready command not relevant for main runtime container so far + //p[] = container.ReadyCommand + } - // Ready command not relevant for main runtime container so far - //p[] = m.Spec.Containers[0].ReadyCommand } if len(m.Spec.Sidecars) > 0 { if len(m.Spec.Sidecars[0].Command) > 0 { - p["sidecarCommand"] = m.Spec.Sidecars[0].Command[0] + root["sidecarCommand"] = m.Spec.Sidecars[0].Command[0] } - p["sidecarEnvVars"] = envVarsAsStringSlice(m.Spec.Sidecars[0].EnvVars) - p["sidecarImage"] = m.Spec.Sidecars[0].Image - p["sidecarName"] = m.Spec.Sidecars[0].Name - p["sidecarPullImage"] = m.Spec.Sidecars[0].ImagePullPolicy != "Never" - p["sidecarReadyCommand"] = m.Spec.Sidecars[0].ReadyCommand - p["sidecarWorkspace"] = m.Spec.Sidecars[0].WorkingDir + root["sidecarEnvVars"] = envVarsAsStringSlice(m.Spec.Sidecars[0].EnvVars) + root["sidecarImage"] = m.Spec.Sidecars[0].Image + root["sidecarName"] = m.Spec.Sidecars[0].Name + root["sidecarPullImage"] = m.Spec.Sidecars[0].ImagePullPolicy != "Never" + root["sidecarReadyCommand"] = m.Spec.Sidecars[0].ReadyCommand + root["sidecarWorkspace"] = m.Spec.Sidecars[0].WorkingDir } // not filled for now since this is not relevant in Kubernetes case - //p["dockerOptions"] = m.Spec.Containers[0]. - //p["dockerVolumeBind"] = m.Spec.Containers[0]. - //p["containerPortMappings"] = m.Spec.Sidecars[0]. - //p["sidecarOptions"] = m.Spec.Sidecars[0]. - //p["sidecarVolumeBind"] = m.Spec.Sidecars[0]. + //p["dockerOptions"] = container. + //p["dockerVolumeBind"] = container. + //root["containerPortMappings"] = m.Spec.Sidecars[0]. + //root["sidecarOptions"] = m.Spec.Sidecars[0]. + //root["sidecarVolumeBind"] = m.Spec.Sidecars[0]. if len(m.Spec.Inputs.Resources) > 0 { - var resources []string + keys := []string{} + resources := map[string][]string{} for _, resource := range m.Spec.Inputs.Resources { if resource.Type == "stash" { - resources = append(resources, resource.Name) + key := "" + if len(resource.Conditions) > 0 { + key = resource.Conditions[0].Params[0].Value + } + if resources[key] == nil { + keys = append(keys, key) + resources[key] = []string{} + } + resources[key] = append(resources[key], resource.Name) + } + } + + for _, key := range keys { + if key == "" { + root["stashContent"] = resources[""] + } else { + if root[key] == nil { + root[key] = map[string]interface{}{ + "stashContent": resources[key], + } + } else { + p := root[key].(map[string]interface{}) + p["stashContent"] = resources[key] + } } } - p["stashContent"] = resources } c := Config{ Steps: map[string]map[string]interface{}{ - stepName: p, + stepName: root, }, } diff --git a/pkg/config/stepmeta_test.go b/pkg/config/stepmeta_test.go index f5ba27fa2..7917ade3b 100644 --- a/pkg/config/stepmeta_test.go +++ b/pkg/config/stepmeta_test.go @@ -68,6 +68,7 @@ func TestGetParameterFilters(t *testing.T) { {Name: "paramFour", Scope: []string{"PARAMETERS", "ENV"}}, {Name: "paramFive", Scope: []string{"ENV"}}, {Name: "paramSix"}, + {Name: "paramSeven", Scope: []string{"GENERAL", "STEPS", "STAGES", "PARAMETERS"}, Conditions: []Condition{{Params: []Param{{Name: "buildTool", Value: "mta"}}}}}, }, }, }, @@ -113,17 +114,17 @@ func TestGetParameterFilters(t *testing.T) { }{ { Metadata: metadata1, - ExpectedGeneral: []string{"paramOne"}, - ExpectedSteps: []string{"paramOne", "paramTwo"}, - ExpectedStages: []string{"paramOne", "paramTwo", "paramThree"}, - ExpectedParameters: []string{"paramOne", "paramTwo", "paramThree", "paramFour"}, - ExpectedEnv: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive"}, - ExpectedAll: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, + ExpectedGeneral: []string{"paramOne", "paramSeven", "mta"}, + ExpectedSteps: []string{"paramOne", "paramTwo", "paramSeven", "mta"}, + ExpectedStages: []string{"paramOne", "paramTwo", "paramThree", "paramSeven", "mta"}, + ExpectedParameters: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramSeven", "mta"}, + ExpectedEnv: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive", "paramSeven", "mta"}, + ExpectedAll: []string{"paramOne", "paramTwo", "paramThree", "paramFour", "paramFive", "paramSix", "paramSeven", "mta"}, NotExpectedGeneral: []string{"paramTwo", "paramThree", "paramFour", "paramFive", "paramSix"}, NotExpectedSteps: []string{"paramThree", "paramFour", "paramFive", "paramSix"}, NotExpectedStages: []string{"paramFour", "paramFive", "paramSix"}, NotExpectedParameters: []string{"paramFive", "paramSix"}, - NotExpectedEnv: []string{"paramSix"}, + NotExpectedEnv: []string{"paramSix", "mta"}, NotExpectedAll: []string{}, }, { @@ -234,6 +235,11 @@ func TestGetContextParameterFilters(t *testing.T) { Spec: StepSpec{ Containers: []Container{ {Name: "testcontainer"}, + {Conditions: []Condition{ + {Params: []Param{ + {Name: "scanType", Value: "pip"}, + }}, + }}, }, }, } @@ -258,12 +264,12 @@ func TestGetContextParameterFilters(t *testing.T) { t.Run("Containers", func(t *testing.T) { filters := metadata2.GetContextParameterFilters() - assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.All, "incorrect filter All") - assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.General, "incorrect filter General") - assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Steps, "incorrect filter Steps") - assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Stages, "incorrect filter Stages") - assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Parameters, "incorrect filter Parameters") - assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace"}, filters.Env, "incorrect filter Env") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.All, "incorrect filter All") + assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.General, "incorrect filter General") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.Steps, "incorrect filter Steps") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.Stages, "incorrect filter Stages") + assert.Equal(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.Parameters, "incorrect filter Parameters") + assert.NotEqual(t, []string{"containerCommand", "containerShell", "dockerEnvVars", "dockerImage", "dockerOptions", "dockerPullImage", "dockerVolumeBind", "dockerWorkspace", "pip"}, filters.Env, "incorrect filter Env") }) t.Run("Sidecars", func(t *testing.T) { @@ -287,15 +293,38 @@ func TestGetContextDefaults(t *testing.T) { { Name: "buildDescriptor", Type: "stash", + Conditions: []Condition{ + {Params: []Param{ + {Name: "scanType", Value: "abc"}, + }}, + }, }, { Name: "source", Type: "stash", + Conditions: []Condition{ + {Params: []Param{ + {Name: "scanType", Value: "abc"}, + }}, + }, }, { Name: "test", Type: "nonce", }, + { + Name: "test2", + Type: "stash", + Conditions: []Condition{ + {Params: []Param{ + {Name: "scanType", Value: "def"}, + }}, + }, + }, + { + Name: "test3", + Type: "stash", + }, }, }, Containers: []Container{ @@ -339,7 +368,9 @@ func TestGetContextDefaults(t *testing.T) { var d PipelineDefaults d.ReadPipelineDefaults([]io.ReadCloser{cd}) - assert.Equal(t, []interface{}{"buildDescriptor", "source"}, d.Defaults[0].Steps["testStep"]["stashContent"], "stashContent default not available") + assert.Equal(t, []interface{}{"buildDescriptor", "source"}, d.Defaults[0].Steps["testStep"]["abc"].(map[string]interface{})["stashContent"], "stashContent default not available") + assert.Equal(t, []interface{}{"test2"}, d.Defaults[0].Steps["testStep"]["def"].(map[string]interface{})["stashContent"], "stashContent default not available") + assert.Equal(t, []interface{}{"test3"}, d.Defaults[0].Steps["testStep"]["stashContent"], "stashContent default not available") assert.Equal(t, "test/command", d.Defaults[0].Steps["testStep"]["containerCommand"], "containerCommand default not available") assert.Equal(t, "testcontainer", d.Defaults[0].Steps["testStep"]["containerName"], "containerName default not available") assert.Equal(t, "/bin/bash", d.Defaults[0].Steps["testStep"]["containerShell"], "containerShell default not available") From 50153f42c73eea70643db9649fb0e9799cd10b22 Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Tue, 5 Nov 2019 14:37:44 +0100 Subject: [PATCH 100/141] Add tests --- cmd/githubPublishRelease.go | 102 +++++- cmd/githubPublishRelease_generated.go | 18 +- cmd/githubPublishRelease_test.go | 346 +++++++++++++++++- .../Success_-_update_asset_test.txt | 1 + .../Success_-_existing_asset_test.txt | 1 + .../Success_-_no_asset_test.txt | 1 + cmd/version_generated.go | 2 - cmd/version_test.go | 21 +- resources/metadata/githubrelease.yaml | 14 +- 9 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 cmd/testdata/TestRunGithubPublishRelease/Success_-_update_asset_test.txt create mode 100644 cmd/testdata/TestUploadReleaseAsset/Success_-_existing_asset_test.txt create mode 100644 cmd/testdata/TestUploadReleaseAsset/Success_-_no_asset_test.txt diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go index aa6044303..701bdeb54 100644 --- a/cmd/githubPublishRelease.go +++ b/cmd/githubPublishRelease.go @@ -3,8 +3,12 @@ package cmd import ( "context" "fmt" + "mime" + "os" + "path/filepath" "strings" + "github.com/SAP/jenkins-library/pkg/log" "github.com/google/go-github/v28/github" "github.com/pkg/errors" @@ -12,8 +16,11 @@ import ( ) type githubRepoClient interface { - GetLatestRelease(ctx context.Context, owner string, repo string) (*github.RepositoryRelease, *github.Response, error) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) + DeleteReleaseAsset(ctx context.Context, owner string, repo string, id int64) (*github.Response, error) + GetLatestRelease(ctx context.Context, owner string, repo string) (*github.RepositoryRelease, *github.Response, error) + ListReleaseAssets(ctx context.Context, owner string, repo string, id int64, opt *github.ListOptions) ([]*github.ReleaseAsset, *github.Response, error) + UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) } type githubIssueClient interface { @@ -21,14 +28,14 @@ type githubIssueClient interface { } func githubPublishRelease(myGithubPublishReleaseOptions githubPublishReleaseOptions) error { - ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.GithubToken, myGithubPublishReleaseOptions.GithubAPIURL, myGithubPublishReleaseOptions.GithubAPIURL) + ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.GithubToken, myGithubPublishReleaseOptions.GithubAPIURL, myGithubPublishReleaseOptions.GithubUploadURL) if err != nil { - return err + log.Entry().WithError(err).Fatal("Failed to get GitHub client.") } err = runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, client.Repositories, client.Issues) if err != nil { - return err + log.Entry().WithError(err).Fatal("Failed to publish GitHub release.") } return nil @@ -37,21 +44,28 @@ func githubPublishRelease(myGithubPublishReleaseOptions githubPublishReleaseOpti func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghRepoClient githubRepoClient, ghIssueClient githubIssueClient) error { var publishedAt github.Timestamp + lastRelease, resp, err := ghRepoClient.GetLatestRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) if err != nil { if resp.StatusCode == 404 { - //first release + //no previous release found -> first release myGithubPublishReleaseOptions.AddDeltaToLastRelease = false - publishedAt = lastRelease.GetPublishedAt() + log.Entry().Debug("This is the first release.") } else { - return errors.Wrap(err, "Error occured when retrieving latest GitHub releass") + return errors.Wrap(err, "Error occured when retrieving latest GitHub release.") } } + publishedAt = lastRelease.GetPublishedAt() + log.Entry().Debugf("Previous GitHub release published: '%v'", publishedAt) + + if myGithubPublishReleaseOptions.UpdateAsset { + return uploadReleaseAsset(ctx, lastRelease.GetID(), myGithubPublishReleaseOptions, ghRepoClient) + } releaseBody := "" if len(myGithubPublishReleaseOptions.ReleaseBodyHeader) > 0 { - releaseBody += myGithubPublishReleaseOptions.ReleaseBodyHeader + "
" + releaseBody += myGithubPublishReleaseOptions.ReleaseBodyHeader + "\n" } if myGithubPublishReleaseOptions.AddClosedIssues { @@ -69,20 +83,22 @@ func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions Body: &releaseBody, } - //create release createdRelease, _, err := ghRepoClient.CreateRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &release) if err != nil { - return errors.Wrapf(err, "creation of release '%v' failed", release.TagName) + return errors.Wrapf(err, "Creation of release '%v' failed", *release.TagName) } + log.Entry().Infof("Release %v created on %v/%v", *createdRelease.TagName, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) - // todo switch to logging - fmt.Printf("Release %v created on %v/%v", *createdRelease.TagName, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) + if len(myGithubPublishReleaseOptions.AssetPath) > 0 { + return uploadReleaseAsset(ctx, createdRelease.GetID(), myGithubPublishReleaseOptions, ghRepoClient) + } return nil } func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghIssueClient githubIssueClient) string { closedIssuesText := "" + options := github.IssueListByRepoOptions{ State: "closed", Direction: "asc", @@ -93,17 +109,19 @@ func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGi } ghIssues, _, err := ghIssueClient.ListByRepo(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &options) if err != nil { - //log error + log.Entry().WithError(err).Error("Failed to get GitHub issues.") } - prTexts := []string{"
**List of closed pull-requests since last release**"} - issueTexts := []string{"
**List of closed issues since last release**"} + prTexts := []string{"\n**List of closed pull-requests since last release**"} + issueTexts := []string{"\n**List of closed issues since last release**"} for _, issue := range ghIssues { if issue.IsPullRequest() && !isExcluded(issue, myGithubPublishReleaseOptions.ExcludeLabels) { prTexts = append(prTexts, fmt.Sprintf("[#%v](%v): %v", issue.GetNumber(), issue.GetHTMLURL(), issue.GetTitle())) + log.Entry().Debugf("Added PR #%v to release", issue.GetNumber()) } else if !issue.IsPullRequest() && !isExcluded(issue, myGithubPublishReleaseOptions.ExcludeLabels) { issueTexts = append(issueTexts, fmt.Sprintf("[#%v](%v): %v", issue.GetNumber(), issue.GetHTMLURL(), issue.GetTitle())) + log.Entry().Debugf("Added Issue #%v to release", issue.GetNumber()) } } @@ -121,9 +139,9 @@ func getReleaseDeltaText(myGithubPublishReleaseOptions *githubPublishReleaseOpti releaseDeltaText := "" //add delta link to previous release - releaseDeltaText += "
**Changes**
" + releaseDeltaText += "\n**Changes**\n" releaseDeltaText += fmt.Sprintf( - "[%v...%v](%v/%v/%v/compare/%v...%v)
", + "[%v...%v](%v/%v/%v/compare/%v...%v)\n", lastRelease.GetTagName(), myGithubPublishReleaseOptions.Version, myGithubPublishReleaseOptions.GithubServerURL, @@ -135,6 +153,56 @@ func getReleaseDeltaText(myGithubPublishReleaseOptions *githubPublishReleaseOpti return releaseDeltaText } +func uploadReleaseAsset(ctx context.Context, releaseID int64, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghRepoClient githubRepoClient) error { + + assets, _, err := ghRepoClient.ListReleaseAssets(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, releaseID, &github.ListOptions{}) + if err != nil { + return errors.Wrap(err, "Failed to get list of release assets.") + } + var assetID int64 + for _, a := range assets { + if a.GetName() == filepath.Base(myGithubPublishReleaseOptions.AssetPath) { + assetID = a.GetID() + break + } + } + if assetID != 0 { + //asset needs to be deleted first since API does not allow for replacement + _, err := ghRepoClient.DeleteReleaseAsset(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, assetID) + if err != nil { + return errors.Wrap(err, "Failed to delete release asset.") + } + } + + mediaType := mime.TypeByExtension(filepath.Ext(myGithubPublishReleaseOptions.AssetPath)) + if mediaType == "" { + mediaType = "application/octet-stream" + } + log.Entry().Debugf("Using mediaType '%v'", mediaType) + + name := filepath.Base(myGithubPublishReleaseOptions.AssetPath) + log.Entry().Debugf("Using file name '%v'", name) + + opts := github.UploadOptions{ + Name: name, + MediaType: mediaType, + } + file, err := os.Open(myGithubPublishReleaseOptions.AssetPath) + defer file.Close() + if err != nil { + return errors.Wrapf(err, "Failed to load release asset '%v'", myGithubPublishReleaseOptions.AssetPath) + } + + log.Entry().Info("Starting to upload release asset.") + asset, _, err := ghRepoClient.UploadReleaseAsset(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, releaseID, &opts, file) + if err != nil { + return errors.Wrap(err, "Failed to upload release asset.") + } + log.Entry().Infof("Done uploading asset '%v'.", asset.GetURL()) + + return nil +} + func isExcluded(issue *github.Issue, excludeLabels []string) bool { //issue.Labels[0].GetName() for _, ex := range excludeLabels { diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go index 10cc6e2aa..3fc00a1b3 100644 --- a/cmd/githubPublishRelease_generated.go +++ b/cmd/githubPublishRelease_generated.go @@ -4,6 +4,7 @@ import ( "os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" ) @@ -18,9 +19,10 @@ type githubPublishReleaseOptions struct { GithubRepo string `json:"githubRepo,omitempty"` GithubServerURL string `json:"githubServerUrl,omitempty"` GithubToken string `json:"githubToken,omitempty"` + GithubUploadURL string `json:"githubUploadUrl,omitempty"` Labels []string `json:"labels,omitempty"` ReleaseBodyHeader string `json:"releaseBodyHeader,omitempty"` - Update bool `json:"update,omitempty"` + UpdateAsset bool `json:"updateAsset,omitempty"` Version string `json:"version,omitempty"` } @@ -44,6 +46,8 @@ The result looks like ![Example release](../images/githubRelease.png)`, PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("githubPublishRelease") + log.SetVerbose(generalConfig.verbose) return PrepareConfig(cmd, &metadata, "githubPublishRelease", &myGithubPublishReleaseOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { @@ -66,9 +70,10 @@ func addGithubPublishReleaseFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubRepo, "githubRepo", os.Getenv("PIPER_githubRepo"), "Set the GitHub repository.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubServerURL, "githubServerUrl", "https://github.com", "GitHub server url for end-user access.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubToken, "githubToken", os.Getenv("PIPER_githubToken"), "GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubUploadURL, "githubUploadUrl", "https://uploads.github.com", "Set the GitHub API url.") cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.Labels, "labels", []string{}, "Labels to include in issue search.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ReleaseBodyHeader, "releaseBodyHeader", os.Getenv("PIPER_releaseBodyHeader"), "Content which will appear for the release.") - cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.Update, "update", false, "Specify if the release should be updated in case it already exists") + cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.UpdateAsset, "updateAsset", false, "Specify if a release asset should be updated only.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Version, "version", os.Getenv("PIPER_version"), "Define the version number which will be written as tag as well as release name.") cmd.MarkFlagRequired("githubApiUrl") @@ -76,6 +81,7 @@ func addGithubPublishReleaseFlags(cmd *cobra.Command) { cmd.MarkFlagRequired("githubRepo") cmd.MarkFlagRequired("githubServerUrl") cmd.MarkFlagRequired("githubToken") + cmd.MarkFlagRequired("githubUploadUrl") cmd.MarkFlagRequired("version") } @@ -145,6 +151,12 @@ func githubPublishReleaseMetadata() config.StepData { Type: "string", Mandatory: true, }, + { + Name: "githubUploadUrl", + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + }, { Name: "labels", Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, @@ -158,7 +170,7 @@ func githubPublishReleaseMetadata() config.StepData { Mandatory: false, }, { - Name: "update", + Name: "updateAsset", Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, Type: "bool", Mandatory: false, diff --git a/cmd/githubPublishRelease_test.go b/cmd/githubPublishRelease_test.go index 33ff34fa3..88ff54ec0 100644 --- a/cmd/githubPublishRelease_test.go +++ b/cmd/githubPublishRelease_test.go @@ -1,15 +1,359 @@ package cmd import ( + "context" "fmt" + "net/http" + "os" + "path/filepath" "testing" + "time" "github.com/google/go-github/v28/github" "github.com/stretchr/testify/assert" ) -func TestRunGithubPublishRelease(t *testing.T) { +type ghRCMock struct { + createErr error + latestRelease *github.RepositoryRelease + release *github.RepositoryRelease + delErr error + delID int64 + delOwner string + delRepo string + listErr error + listID int64 + listOwner string + listReleaseAssets []*github.ReleaseAsset + listRepo string + listOpts *github.ListOptions + latestStatusCode int + latestErr error + uploadID int64 + uploadOpts *github.UploadOptions + uploadOwner string + uploadRepo string +} +func (g *ghRCMock) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { + g.release = release + return release, nil, g.createErr +} + +func (g *ghRCMock) DeleteReleaseAsset(ctx context.Context, owner string, repo string, id int64) (*github.Response, error) { + g.delOwner = owner + g.delRepo = repo + g.delID = id + return nil, g.delErr +} + +func (g *ghRCMock) GetLatestRelease(ctx context.Context, owner string, repo string) (*github.RepositoryRelease, *github.Response, error) { + hc := http.Response{StatusCode: 200} + if g.latestStatusCode != 0 { + hc.StatusCode = g.latestStatusCode + } + ghResp := github.Response{Response: &hc} + return g.latestRelease, &ghResp, g.latestErr +} + +func (g *ghRCMock) ListReleaseAssets(ctx context.Context, owner string, repo string, id int64, opt *github.ListOptions) ([]*github.ReleaseAsset, *github.Response, error) { + g.listID = id + g.listOwner = owner + g.listRepo = repo + g.listOpts = opt + return g.listReleaseAssets, nil, g.listErr +} + +func (g *ghRCMock) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { + g.uploadID = id + g.uploadOwner = owner + g.uploadRepo = repo + g.uploadOpts = opt + return nil, nil, nil +} + +type ghICMock struct { + issues []*github.Issue + lastPublished time.Time + owner string + repo string + options *github.IssueListByRepoOptions +} + +func (g *ghICMock) ListByRepo(ctx context.Context, owner string, repo string, opt *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) { + g.owner = owner + g.repo = repo + g.options = opt + g.lastPublished = opt.Since + return g.issues, nil, nil +} + +func TestRunGithubPublishRelease(t *testing.T) { + ctx := context.Background() + + t.Run("Success - first release & no body", func(t *testing.T) { + ghIssueClient := ghICMock{} + ghRepoClient := ghRCMock{ + latestStatusCode: 404, + latestErr: fmt.Errorf("not found"), + } + + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + AddDeltaToLastRelease: true, + Commitish: "master", + GithubOrg: "TEST", + GithubRepo: "test", + GithubServerURL: "https://github.com", + ReleaseBodyHeader: "Header", + Version: "1.0", + } + err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) + assert.NoError(t, err, "Error occured but none expected.") + + assert.Equal(t, "Header\n", ghRepoClient.release.GetBody()) + }) + + t.Run("Success - subsequent releases & with body", func(t *testing.T) { + lastTag := "1.0" + lastPublishedAt := github.Timestamp{Time: time.Date(2019, 01, 01, 0, 0, 0, 0, time.UTC)} + ghRepoClient := ghRCMock{ + createErr: nil, + latestRelease: &github.RepositoryRelease{ + TagName: &lastTag, + PublishedAt: &lastPublishedAt, + }, + } + prHTMLURL := "https://github.com/TEST/test/pull/1" + prTitle := "Pull" + prNo := 1 + + issHTMLURL := "https://github.com/TEST/test/issues/2" + issTitle := "Issue" + issNo := 2 + + ghIssueClient := ghICMock{ + issues: []*github.Issue{ + {Number: &prNo, Title: &prTitle, HTMLURL: &prHTMLURL, PullRequestLinks: &github.PullRequestLinks{URL: &prHTMLURL}}, + {Number: &issNo, Title: &issTitle, HTMLURL: &issHTMLURL}, + }, + } + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + AddClosedIssues: true, + AddDeltaToLastRelease: true, + Commitish: "master", + GithubOrg: "TEST", + GithubRepo: "test", + GithubServerURL: "https://github.com", + ReleaseBodyHeader: "Header", + Version: "1.1", + } + err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) + + assert.NoError(t, err, "Error occured but none expected.") + + assert.Equal(t, "Header\n\n**List of closed pull-requests since last release**\n[#1](https://github.com/TEST/test/pull/1): Pull\n\n**List of closed issues since last release**\n[#2](https://github.com/TEST/test/issues/2): Issue\n\n**Changes**\n[1.0...1.1](https://github.com/TEST/test/compare/1.0...1.1)\n", ghRepoClient.release.GetBody()) + assert.Equal(t, "1.1", ghRepoClient.release.GetName()) + assert.Equal(t, "1.1", ghRepoClient.release.GetTagName()) + assert.Equal(t, "master", ghRepoClient.release.GetTargetCommitish()) + + assert.Equal(t, lastPublishedAt.Time, ghIssueClient.lastPublished) + }) + + t.Run("Success - update asset", func(t *testing.T) { + var releaseID int64 = 1 + ghIssueClient := ghICMock{} + ghRepoClient := ghRCMock{ + latestRelease: &github.RepositoryRelease{ + ID: &releaseID, + }, + } + + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + UpdateAsset: true, + AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), + } + + err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) + + assert.NoError(t, err, "Error occured but none expected.") + + assert.Nil(t, ghRepoClient.release) + + assert.Equal(t, releaseID, ghRepoClient.listID) + assert.Equal(t, releaseID, ghRepoClient.uploadID) + }) + + t.Run("Error - get release", func(t *testing.T) { + ghIssueClient := ghICMock{} + ghRepoClient := ghRCMock{ + latestErr: fmt.Errorf("Latest release error"), + } + myGithubPublishReleaseOptions := githubPublishReleaseOptions{} + err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) + + assert.Equal(t, "Error occured when retrieving latest GitHub release.: Latest release error", fmt.Sprint(err)) + }) + + t.Run("Error - create release", func(t *testing.T) { + ghIssueClient := ghICMock{} + ghRepoClient := ghRCMock{ + createErr: fmt.Errorf("Create release error"), + } + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + Version: "1.0", + } + err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) + + assert.Equal(t, "Creation of release '1.0' failed: Create release error", fmt.Sprint(err)) + }) +} + +func TestGetClosedIssuesText(t *testing.T) { + ctx := context.Background() + publishedAt := github.Timestamp{Time: time.Date(2019, 01, 01, 0, 0, 0, 0, time.UTC)} + + t.Run("No issues", func(t *testing.T) { + ghIssueClient := ghICMock{} + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + Version: "1.0", + } + + res := getClosedIssuesText(ctx, publishedAt, &myGithubPublishReleaseOptions, &ghIssueClient) + + assert.Equal(t, "", res) + }) + + t.Run("All issues", func(t *testing.T) { + ctx := context.Background() + publishedAt := github.Timestamp{Time: time.Date(2019, 01, 01, 0, 0, 0, 0, time.UTC)} + + prHTMLURL := []string{"https://github.com/TEST/test/pull/1", "https://github.com/TEST/test/pull/2"} + prTitle := []string{"Pull1", "Pull2"} + prNo := []int{1, 2} + + issHTMLURL := []string{"https://github.com/TEST/test/issues/3", "https://github.com/TEST/test/issues/4"} + issTitle := []string{"Issue3", "Issue4"} + issNo := []int{3, 4} + + ghIssueClient := ghICMock{ + issues: []*github.Issue{ + {Number: &prNo[0], Title: &prTitle[0], HTMLURL: &prHTMLURL[0], PullRequestLinks: &github.PullRequestLinks{URL: &prHTMLURL[0]}}, + {Number: &prNo[1], Title: &prTitle[1], HTMLURL: &prHTMLURL[1], PullRequestLinks: &github.PullRequestLinks{URL: &prHTMLURL[1]}}, + {Number: &issNo[0], Title: &issTitle[0], HTMLURL: &issHTMLURL[0]}, + {Number: &issNo[1], Title: &issTitle[1], HTMLURL: &issHTMLURL[1]}, + }, + } + + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + GithubOrg: "TEST", + GithubRepo: "test", + } + + res := getClosedIssuesText(ctx, publishedAt, &myGithubPublishReleaseOptions, &ghIssueClient) + + assert.Equal(t, "\n**List of closed pull-requests since last release**\n[#1](https://github.com/TEST/test/pull/1): Pull1\n[#2](https://github.com/TEST/test/pull/2): Pull2\n\n**List of closed issues since last release**\n[#3](https://github.com/TEST/test/issues/3): Issue3\n[#4](https://github.com/TEST/test/issues/4): Issue4\n", res) + assert.Equal(t, "TEST", ghIssueClient.owner, "Owner not properly passed") + assert.Equal(t, "test", ghIssueClient.repo, "Repo not properly passed") + assert.Equal(t, "closed", ghIssueClient.options.State, "Issue state not properly passed") + assert.Equal(t, "asc", ghIssueClient.options.Direction, "Sort direction not properly passed") + assert.Equal(t, publishedAt.Time, ghIssueClient.options.Since, "PublishedAt not properly passed") + }) + +} + +func TestGetReleaseDeltaText(t *testing.T) { + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + GithubOrg: "TEST", + GithubRepo: "test", + GithubServerURL: "https://github.com", + Version: "1.1", + } + lastTag := "1.0" + lastRelease := github.RepositoryRelease{ + TagName: &lastTag, + } + + res := getReleaseDeltaText(&myGithubPublishReleaseOptions, &lastRelease) + + assert.Equal(t, "\n**Changes**\n[1.0...1.1](https://github.com/TEST/test/compare/1.0...1.1)\n", res) +} + +func TestUploadReleaseAsset(t *testing.T) { + ctx := context.Background() + + t.Run("Success - existing asset", func(t *testing.T) { + var releaseID int64 = 1 + assetName := "Success_-_existing_asset_test.txt" + var assetID int64 = 11 + ghRepoClient := ghRCMock{ + latestRelease: &github.RepositoryRelease{ + ID: &releaseID, + }, + listReleaseAssets: []*github.ReleaseAsset{ + {Name: &assetName, ID: &assetID}, + }, + } + + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + GithubOrg: "TEST", + GithubRepo: "test", + AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), + } + + err := uploadReleaseAsset(ctx, releaseID, &myGithubPublishReleaseOptions, &ghRepoClient) + + assert.NoError(t, err, "Error occured but none expected.") + + assert.Equal(t, "TEST", ghRepoClient.listOwner, "Owner not properly passed - list") + assert.Equal(t, "test", ghRepoClient.listRepo, "Repo not properly passed - list") + assert.Equal(t, releaseID, ghRepoClient.listID, "Relase ID not properly passed - list") + + assert.Equal(t, "TEST", ghRepoClient.delOwner, "Owner not properly passed - del") + assert.Equal(t, "test", ghRepoClient.delRepo, "Repo not properly passed - del") + assert.Equal(t, assetID, ghRepoClient.delID, "Relase ID not properly passed - del") + + assert.Equal(t, "TEST", ghRepoClient.uploadOwner, "Owner not properly passed - upload") + assert.Equal(t, "test", ghRepoClient.uploadRepo, "Repo not properly passed - upload") + assert.Equal(t, releaseID, ghRepoClient.uploadID, "Relase ID not properly passed - upload") + assert.Equal(t, "text/plain; charset=utf-8", ghRepoClient.uploadOpts.MediaType, "Wrong MediaType passed - upload") + }) + + t.Run("Success - no asset", func(t *testing.T) { + var releaseID int64 = 1 + assetName := "notFound" + var assetID int64 = 11 + ghRepoClient := ghRCMock{ + latestRelease: &github.RepositoryRelease{ + ID: &releaseID, + }, + listReleaseAssets: []*github.ReleaseAsset{ + {Name: &assetName, ID: &assetID}, + }, + } + + myGithubPublishReleaseOptions := githubPublishReleaseOptions{ + GithubOrg: "TEST", + GithubRepo: "test", + AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), + } + + err := uploadReleaseAsset(ctx, releaseID, &myGithubPublishReleaseOptions, &ghRepoClient) + + assert.NoError(t, err, "Error occured but none expected.") + + assert.Equal(t, int64(0), ghRepoClient.delID, "Relase ID should not be populated") + }) + + t.Run("Error - List Assets", func(t *testing.T) { + var releaseID int64 = 1 + ghRepoClient := ghRCMock{ + listErr: fmt.Errorf("List Asset Error"), + } + myGithubPublishReleaseOptions := githubPublishReleaseOptions{} + + err := uploadReleaseAsset(ctx, releaseID, &myGithubPublishReleaseOptions, &ghRepoClient) + assert.Equal(t, "Failed to get list of release assets.: List Asset Error", fmt.Sprint(err), "Wrong error received") + }) } func TestIsExcluded(t *testing.T) { diff --git a/cmd/testdata/TestRunGithubPublishRelease/Success_-_update_asset_test.txt b/cmd/testdata/TestRunGithubPublishRelease/Success_-_update_asset_test.txt new file mode 100644 index 000000000..3b1246497 --- /dev/null +++ b/cmd/testdata/TestRunGithubPublishRelease/Success_-_update_asset_test.txt @@ -0,0 +1 @@ +TEST \ No newline at end of file diff --git a/cmd/testdata/TestUploadReleaseAsset/Success_-_existing_asset_test.txt b/cmd/testdata/TestUploadReleaseAsset/Success_-_existing_asset_test.txt new file mode 100644 index 000000000..3b1246497 --- /dev/null +++ b/cmd/testdata/TestUploadReleaseAsset/Success_-_existing_asset_test.txt @@ -0,0 +1 @@ +TEST \ No newline at end of file diff --git a/cmd/testdata/TestUploadReleaseAsset/Success_-_no_asset_test.txt b/cmd/testdata/TestUploadReleaseAsset/Success_-_no_asset_test.txt new file mode 100644 index 000000000..3b1246497 --- /dev/null +++ b/cmd/testdata/TestUploadReleaseAsset/Success_-_no_asset_test.txt @@ -0,0 +1 @@ +TEST \ No newline at end of file diff --git a/cmd/version_generated.go b/cmd/version_generated.go index 2b4802299..d0d8c2152 100644 --- a/cmd/version_generated.go +++ b/cmd/version_generated.go @@ -1,8 +1,6 @@ package cmd import ( - //"os" - "github.com/SAP/jenkins-library/pkg/config" "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" diff --git a/cmd/version_test.go b/cmd/version_test.go index 5135bb4b5..a3daffb87 100644 --- a/cmd/version_test.go +++ b/cmd/version_test.go @@ -1,11 +1,11 @@ package cmd import ( - "testing" - "os" "bytes" - "io" "github.com/stretchr/testify/assert" + "io" + "os" + "testing" ) func TestVersion(t *testing.T) { @@ -17,7 +17,6 @@ func TestVersion(t *testing.T) { assert.Contains(t, result, "tag: \"\"") }) - t.Run("versionAndTagSet", func(t *testing.T) { result := runVersionCommand(t, "16bafe", "v1.2.3") @@ -29,9 +28,9 @@ func TestVersion(t *testing.T) { func runVersionCommand(t *testing.T, commitID, tag string) string { orig := os.Stdout - defer func() {os.Stdout = orig}() + defer func() { os.Stdout = orig }() - r,w,e := os.Pipe() + r, w, e := os.Pipe() if e != nil { t.Error("Cannot setup pipes.") } @@ -41,8 +40,12 @@ func runVersionCommand(t *testing.T, commitID, tag string) string { // // needs to be set in the free wild by the build process: // go build -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT} -X github.com/SAP/jenkins-library/cmd.GitTag=${GIT_TAG}" - if len(commitID) > 0 { GitCommit = commitID; } - if len(tag) > 0 { GitTag = tag } + if len(commitID) > 0 { + GitCommit = commitID + } + if len(tag) > 0 { + GitTag = tag + } defer func() { GitCommit = ""; GitTag = "" }() // // @@ -58,4 +61,4 @@ func runVersionCommand(t *testing.T, commitID, tag string) string { var buf bytes.Buffer io.Copy(&buf, r) return buf.String() -} \ No newline at end of file +} diff --git a/resources/metadata/githubrelease.yaml b/resources/metadata/githubrelease.yaml index 07b0ae1ce..297dd7ead 100644 --- a/resources/metadata/githubrelease.yaml +++ b/resources/metadata/githubrelease.yaml @@ -101,6 +101,16 @@ spec: - STEPS type: string mandatory: true + - name: githubUploadUrl + description: Set the GitHub API url. + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + type: string + default: https://uploads.github.com + mandatory: true - name: labels description: 'Labels to include in issue search.' scope: @@ -115,8 +125,8 @@ spec: - STAGES - STEPS type: string - - name: update - description: Specify if the release should be updated in case it already exists + - name: updateAsset + description: Specify if a release asset should be updated only. scope: - PARAMETERS - STAGES From 44473666a884051a2b1d5f76466d3e6bd46021ed Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Tue, 5 Nov 2019 14:46:45 +0100 Subject: [PATCH 101/141] Address CodeClimate findings --- resources/metadata/githubrelease.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/metadata/githubrelease.yaml b/resources/metadata/githubrelease.yaml index 297dd7ead..580171334 100644 --- a/resources/metadata/githubrelease.yaml +++ b/resources/metadata/githubrelease.yaml @@ -4,13 +4,13 @@ metadata: longDescription: | This step creates a tag in your GitHub repository together with a release. The release can be filled with text plus additional information like: - + * Closed pull request since last release * Closed issues since last release * Link to delta information showing all commits since last release - + The result looks like - + ![Example release](../images/githubRelease.png) spec: inputs: From 1a034aea4cc2c370f3c5d344824a231a0e796e5d Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Tue, 5 Nov 2019 15:13:04 +0100 Subject: [PATCH 102/141] Update parameter names --- cmd/githubPublishRelease.go | 22 ++++++------ cmd/githubPublishRelease_generated.go | 48 +++++++++++++-------------- cmd/githubPublishRelease_test.go | 32 +++++++++--------- resources/metadata/githubrelease.yaml | 24 ++++++++++---- 4 files changed, 69 insertions(+), 57 deletions(-) diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go index 701bdeb54..3d18f8e71 100644 --- a/cmd/githubPublishRelease.go +++ b/cmd/githubPublishRelease.go @@ -28,7 +28,7 @@ type githubIssueClient interface { } func githubPublishRelease(myGithubPublishReleaseOptions githubPublishReleaseOptions) error { - ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.GithubToken, myGithubPublishReleaseOptions.GithubAPIURL, myGithubPublishReleaseOptions.GithubUploadURL) + ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.Token, myGithubPublishReleaseOptions.ApiURL, myGithubPublishReleaseOptions.UploadURL) if err != nil { log.Entry().WithError(err).Fatal("Failed to get GitHub client.") } @@ -45,7 +45,7 @@ func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions var publishedAt github.Timestamp - lastRelease, resp, err := ghRepoClient.GetLatestRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) + lastRelease, resp, err := ghRepoClient.GetLatestRelease(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository) if err != nil { if resp.StatusCode == 404 { //no previous release found -> first release @@ -83,11 +83,11 @@ func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions Body: &releaseBody, } - createdRelease, _, err := ghRepoClient.CreateRelease(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &release) + createdRelease, _, err := ghRepoClient.CreateRelease(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository, &release) if err != nil { return errors.Wrapf(err, "Creation of release '%v' failed", *release.TagName) } - log.Entry().Infof("Release %v created on %v/%v", *createdRelease.TagName, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo) + log.Entry().Infof("Release %v created on %v/%v", *createdRelease.TagName, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository) if len(myGithubPublishReleaseOptions.AssetPath) > 0 { return uploadReleaseAsset(ctx, createdRelease.GetID(), myGithubPublishReleaseOptions, ghRepoClient) @@ -107,7 +107,7 @@ func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGi if len(myGithubPublishReleaseOptions.Labels) > 0 { options.Labels = myGithubPublishReleaseOptions.Labels } - ghIssues, _, err := ghIssueClient.ListByRepo(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, &options) + ghIssues, _, err := ghIssueClient.ListByRepo(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository, &options) if err != nil { log.Entry().WithError(err).Error("Failed to get GitHub issues.") } @@ -144,9 +144,9 @@ func getReleaseDeltaText(myGithubPublishReleaseOptions *githubPublishReleaseOpti "[%v...%v](%v/%v/%v/compare/%v...%v)\n", lastRelease.GetTagName(), myGithubPublishReleaseOptions.Version, - myGithubPublishReleaseOptions.GithubServerURL, - myGithubPublishReleaseOptions.GithubOrg, - myGithubPublishReleaseOptions.GithubRepo, + myGithubPublishReleaseOptions.ServerURL, + myGithubPublishReleaseOptions.Owner, + myGithubPublishReleaseOptions.Repository, lastRelease.GetTagName(), myGithubPublishReleaseOptions.Version, ) @@ -155,7 +155,7 @@ func getReleaseDeltaText(myGithubPublishReleaseOptions *githubPublishReleaseOpti func uploadReleaseAsset(ctx context.Context, releaseID int64, myGithubPublishReleaseOptions *githubPublishReleaseOptions, ghRepoClient githubRepoClient) error { - assets, _, err := ghRepoClient.ListReleaseAssets(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, releaseID, &github.ListOptions{}) + assets, _, err := ghRepoClient.ListReleaseAssets(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository, releaseID, &github.ListOptions{}) if err != nil { return errors.Wrap(err, "Failed to get list of release assets.") } @@ -168,7 +168,7 @@ func uploadReleaseAsset(ctx context.Context, releaseID int64, myGithubPublishRel } if assetID != 0 { //asset needs to be deleted first since API does not allow for replacement - _, err := ghRepoClient.DeleteReleaseAsset(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, assetID) + _, err := ghRepoClient.DeleteReleaseAsset(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository, assetID) if err != nil { return errors.Wrap(err, "Failed to delete release asset.") } @@ -194,7 +194,7 @@ func uploadReleaseAsset(ctx context.Context, releaseID int64, myGithubPublishRel } log.Entry().Info("Starting to upload release asset.") - asset, _, err := ghRepoClient.UploadReleaseAsset(ctx, myGithubPublishReleaseOptions.GithubOrg, myGithubPublishReleaseOptions.GithubRepo, releaseID, &opts, file) + asset, _, err := ghRepoClient.UploadReleaseAsset(ctx, myGithubPublishReleaseOptions.Owner, myGithubPublishReleaseOptions.Repository, releaseID, &opts, file) if err != nil { return errors.Wrap(err, "Failed to upload release asset.") } diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go index 3fc00a1b3..9ba10ffae 100644 --- a/cmd/githubPublishRelease_generated.go +++ b/cmd/githubPublishRelease_generated.go @@ -14,12 +14,12 @@ type githubPublishReleaseOptions struct { AssetPath string `json:"assetPath,omitempty"` Commitish string `json:"commitish,omitempty"` ExcludeLabels []string `json:"excludeLabels,omitempty"` - GithubAPIURL string `json:"githubApiUrl,omitempty"` - GithubOrg string `json:"githubOrg,omitempty"` - GithubRepo string `json:"githubRepo,omitempty"` - GithubServerURL string `json:"githubServerUrl,omitempty"` - GithubToken string `json:"githubToken,omitempty"` - GithubUploadURL string `json:"githubUploadUrl,omitempty"` + ApiURL string `json:"apiUrl,omitempty"` + Owner string `json:"owner,omitempty"` + Repository string `json:"repository,omitempty"` + ServerURL string `json:"serverUrl,omitempty"` + Token string `json:"token,omitempty"` + UploadURL string `json:"uploadUrl,omitempty"` Labels []string `json:"labels,omitempty"` ReleaseBodyHeader string `json:"releaseBodyHeader,omitempty"` UpdateAsset bool `json:"updateAsset,omitempty"` @@ -65,23 +65,23 @@ func addGithubPublishReleaseFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&myGithubPublishReleaseOptions.AssetPath, "assetPath", os.Getenv("PIPER_assetPath"), "Path to a release asset which should be uploaded to the list of release assets.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Commitish, "commitish", "master", "Target git commitish for the release") cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.ExcludeLabels, "excludeLabels", []string{}, "Allows to exclude issues with dedicated list of labels.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubAPIURL, "githubApiUrl", "https://api.github.com", "Set the GitHub API url.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubOrg, "githubOrg", os.Getenv("PIPER_githubOrg"), "Set the GitHub organization.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubRepo, "githubRepo", os.Getenv("PIPER_githubRepo"), "Set the GitHub repository.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubServerURL, "githubServerUrl", "https://github.com", "GitHub server url for end-user access.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubToken, "githubToken", os.Getenv("PIPER_githubToken"), "GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.GithubUploadURL, "githubUploadUrl", "https://uploads.github.com", "Set the GitHub API url.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ApiURL, "apiUrl", "https://api.github.com", "Set the GitHub API url.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Owner, "owner", os.Getenv("PIPER_owner"), "Set the GitHub organization.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Repository, "repository", os.Getenv("PIPER_repository"), "Set the GitHub repository.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ServerURL, "serverUrl", "https://github.com", "GitHub server url for end-user access.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Token, "token", os.Getenv("PIPER_token"), "GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.UploadURL, "uploadUrl", "https://uploads.github.com", "Set the GitHub API url.") cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.Labels, "labels", []string{}, "Labels to include in issue search.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ReleaseBodyHeader, "releaseBodyHeader", os.Getenv("PIPER_releaseBodyHeader"), "Content which will appear for the release.") cmd.Flags().BoolVar(&myGithubPublishReleaseOptions.UpdateAsset, "updateAsset", false, "Specify if a release asset should be updated only.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Version, "version", os.Getenv("PIPER_version"), "Define the version number which will be written as tag as well as release name.") - cmd.MarkFlagRequired("githubApiUrl") - cmd.MarkFlagRequired("githubOrg") - cmd.MarkFlagRequired("githubRepo") - cmd.MarkFlagRequired("githubServerUrl") - cmd.MarkFlagRequired("githubToken") - cmd.MarkFlagRequired("githubUploadUrl") + cmd.MarkFlagRequired("apiUrl") + cmd.MarkFlagRequired("owner") + cmd.MarkFlagRequired("repository") + cmd.MarkFlagRequired("serverUrl") + cmd.MarkFlagRequired("token") + cmd.MarkFlagRequired("uploadUrl") cmd.MarkFlagRequired("version") } @@ -122,37 +122,37 @@ func githubPublishReleaseMetadata() config.StepData { Mandatory: false, }, { - Name: "githubApiUrl", + Name: "apiUrl", Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, }, { - Name: "githubOrg", + Name: "owner", Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, }, { - Name: "githubRepo", + Name: "repository", Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, }, { - Name: "githubServerUrl", + Name: "serverUrl", Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, }, { - Name: "githubToken", + Name: "token", Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, }, { - Name: "githubUploadUrl", + Name: "uploadUrl", Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, Type: "string", Mandatory: true, diff --git a/cmd/githubPublishRelease_test.go b/cmd/githubPublishRelease_test.go index 88ff54ec0..9008d3e8c 100644 --- a/cmd/githubPublishRelease_test.go +++ b/cmd/githubPublishRelease_test.go @@ -101,9 +101,9 @@ func TestRunGithubPublishRelease(t *testing.T) { myGithubPublishReleaseOptions := githubPublishReleaseOptions{ AddDeltaToLastRelease: true, Commitish: "master", - GithubOrg: "TEST", - GithubRepo: "test", - GithubServerURL: "https://github.com", + Owner: "TEST", + Repository: "test", + ServerURL: "https://github.com", ReleaseBodyHeader: "Header", Version: "1.0", } @@ -141,9 +141,9 @@ func TestRunGithubPublishRelease(t *testing.T) { AddClosedIssues: true, AddDeltaToLastRelease: true, Commitish: "master", - GithubOrg: "TEST", - GithubRepo: "test", - GithubServerURL: "https://github.com", + Owner: "TEST", + Repository: "test", + ServerURL: "https://github.com", ReleaseBodyHeader: "Header", Version: "1.1", } @@ -245,8 +245,8 @@ func TestGetClosedIssuesText(t *testing.T) { } myGithubPublishReleaseOptions := githubPublishReleaseOptions{ - GithubOrg: "TEST", - GithubRepo: "test", + Owner: "TEST", + Repository: "test", } res := getClosedIssuesText(ctx, publishedAt, &myGithubPublishReleaseOptions, &ghIssueClient) @@ -263,10 +263,10 @@ func TestGetClosedIssuesText(t *testing.T) { func TestGetReleaseDeltaText(t *testing.T) { myGithubPublishReleaseOptions := githubPublishReleaseOptions{ - GithubOrg: "TEST", - GithubRepo: "test", - GithubServerURL: "https://github.com", - Version: "1.1", + Owner: "TEST", + Repository: "test", + ServerURL: "https://github.com", + Version: "1.1", } lastTag := "1.0" lastRelease := github.RepositoryRelease{ @@ -295,8 +295,8 @@ func TestUploadReleaseAsset(t *testing.T) { } myGithubPublishReleaseOptions := githubPublishReleaseOptions{ - GithubOrg: "TEST", - GithubRepo: "test", + Owner: "TEST", + Repository: "test", AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), } @@ -332,8 +332,8 @@ func TestUploadReleaseAsset(t *testing.T) { } myGithubPublishReleaseOptions := githubPublishReleaseOptions{ - GithubOrg: "TEST", - GithubRepo: "test", + Owner: "TEST", + Repository: "test", AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), } diff --git a/resources/metadata/githubrelease.yaml b/resources/metadata/githubrelease.yaml index 580171334..8ed7b4d23 100644 --- a/resources/metadata/githubrelease.yaml +++ b/resources/metadata/githubrelease.yaml @@ -57,7 +57,9 @@ spec: - STAGES - STEPS type: '[]string' - - name: githubApiUrl + - name: apiUrl + aliases: + - name: githubApiUrl description: Set the GitHub API url. scope: - GENERAL @@ -67,7 +69,9 @@ spec: type: string default: https://api.github.com mandatory: true - - name: githubOrg + - name: owner + aliases: + - name: githubOrg description: 'Set the GitHub organization.' scope: - PARAMETERS @@ -75,7 +79,9 @@ spec: - STEPS type: string mandatory: true - - name: githubRepo + - name: repository + aliases: + - name: githubRepo description: 'Set the GitHub repository.' scope: - PARAMETERS @@ -83,7 +89,9 @@ spec: - STEPS type: string mandatory: true - - name: githubServerUrl + - name: serverUrl + aliases: + - name: githubServerUrl description: 'GitHub server url for end-user access.' scope: - PARAMETERS @@ -92,7 +100,9 @@ spec: type: string default: https://github.com mandatory: true - - name: githubToken + - name: token + aliases: + - name: githubToken description: 'GitHub personal access token as per https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line' scope: - GENERAL @@ -101,7 +111,9 @@ spec: - STEPS type: string mandatory: true - - name: githubUploadUrl + - name: uploadUrl + aliases: + - name: githubUploadUrl description: Set the GitHub API url. scope: - GENERAL From 5dfc90f386e06a285fc24537c62808e223c8162c Mon Sep 17 00:00:00 2001 From: Maximilian Lenkeit Date: Tue, 5 Nov 2019 15:33:18 +0100 Subject: [PATCH 103/141] Make cobertura defaults compatible with UI5 (#941) * tests(testsPublishResults): evaluate file pattern for cobertura * tests(testsPublishResults): test for cobertura in UI5 projects * feat(testsPublishResults): collect cobertura of UI5 projects by default --- pom.xml | 7 +++++++ resources/default_pipeline_environment.yml | 2 +- test/groovy/TestsPublishResultsTest.groovy | 15 +++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 6231dd661..5d9913115 100644 --- a/pom.xml +++ b/pom.xml @@ -139,6 +139,13 @@ test + + fr.opensagres.js + minimatch.java + 1.1.0 + test + + diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index f8b0c7b64..675ff6177 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -526,7 +526,7 @@ steps: archive: false active: false cobertura: - pattern: '**/target/coverage/cobertura-coverage.xml' + pattern: '**/target/coverage/**/cobertura-coverage.xml' onlyStableBuilds: true allowEmptyResults: true archive: false diff --git a/test/groovy/TestsPublishResultsTest.groovy b/test/groovy/TestsPublishResultsTest.groovy index 3a4b9ff10..685bb3d24 100644 --- a/test/groovy/TestsPublishResultsTest.groovy +++ b/test/groovy/TestsPublishResultsTest.groovy @@ -12,6 +12,7 @@ import static org.junit.Assert.assertEquals import static org.junit.Assert.assertTrue import util.Rules +import minimatch.Minimatch class TestsPublishResultsTest extends BasePiperTest { Map publisherStepOptions @@ -90,14 +91,20 @@ class TestsPublishResultsTest extends BasePiperTest { stepRule.step.testsPublishResults(script: nullScript, jacoco: true, cobertura: true) assertTrue('JaCoCo options are empty', publisherStepOptions.jacoco != null) - assertTrue('Cobertura options are empty', publisherStepOptions.cobertura != null) assertEquals('JaCoCo default pattern not set correct', '**/target/*.exec', publisherStepOptions.jacoco.execPattern) - assertEquals('Cobertura default pattern not set correct', - '**/target/coverage/cobertura-coverage.xml', publisherStepOptions.cobertura.coberturaReportFile) // ensure nothing else is published assertTrue('JUnit options are not empty', publisherStepOptions.junit == null) assertTrue('JMeter options are not empty', publisherStepOptions.jmeter == null) + + assertTrue('Cobertura options are empty', publisherStepOptions.cobertura != null) + assertTrue('Cobertura default pattern is empty', publisherStepOptions.cobertura.coberturaReportFile != null) + String sampleCoberturaPathForJava = 'my/workspace/my/project/target/coverage/cobertura-coverage.xml' + assertTrue('Cobertura default pattern does not match files at target/coverage/cobertura-coverage.xml for Java projects', + Minimatch.minimatch(sampleCoberturaPathForJava, publisherStepOptions.cobertura.coberturaReportFile)) + String sampleCoberturaPathForKarma = 'my/workspace/my/project/target/coverage/Chrome 78.0.3904 (Mac OS X 10.14.6)/cobertura-coverage.xml' + assertTrue('Cobertura default pattern does not match files at target/coverage//cobertura-coverage.xml for UI5 projects', + Minimatch.minimatch(sampleCoberturaPathForKarma, publisherStepOptions.cobertura.coberturaReportFile)) } @Test @@ -145,7 +152,7 @@ class TestsPublishResultsTest extends BasePiperTest { }] }] } - + stepRule.step.testsPublishResults(script: nullScript) assertJobStatusSuccess() } From 376419e0dbfb047895708c8201e51c420394e320 Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Tue, 5 Nov 2019 16:30:41 +0100 Subject: [PATCH 104/141] Add mixin of dependent defaults --- .gitignore | 1 + go.mod | 1 + go.sum | 2 ++ pkg/config/config.go | 19 +++++++++++++- pkg/config/config_test.go | 52 +++++++++++++++++++++++++++++++-------- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 8225066ba..570cfac54 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ reports .classpath .project *~ +.vscode # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* diff --git a/go.mod b/go.mod index 2c87f65d7..dd84712ba 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.13 require ( github.com/ghodss/yaml v1.0.0 + github.com/google/go-cmp v0.3.1 github.com/pkg/errors v0.8.1 github.com/spf13/cobra v0.0.5 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index 796e8ee0a..e65e99c2c 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= diff --git a/pkg/config/config.go b/pkg/config/config.go index 622e9dffb..bc1bfce22 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/ghodss/yaml" + "github.com/google/go-cmp/cmp" "github.com/pkg/errors" ) @@ -133,6 +134,22 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri stepConfig.mixIn(flagValues, filters.Parameters) } + // finally do the condition evaluation post processing + for _, p := range parameters { + if len(p.Conditions) > 0 { + cp := p.Conditions[0].Params[0] + dependentValue := stepConfig.Config[cp.Name] + if cmp.Equal(dependentValue, cp.Value) && stepConfig.Config[p.Name] == nil { + subMapValue := stepConfig.Config[dependentValue.(string)].(map[string]interface{})[p.Name] + if subMapValue != nil { + stepConfig.Config[p.Name] = subMapValue + } else { + stepConfig.Config[p.Name] = p.Default + } + } + } + } + return stepConfig, nil } @@ -180,7 +197,7 @@ func (s *StepConfig) mixIn(mergeData map[string]interface{}, filter []string) { s.Config = map[string]interface{}{} } - s.Config = filterMap(merge(s.Config, mergeData), filter) + s.Config = merge(s.Config, filterMap(mergeData, filter)) } func filterMap(data map[string]interface{}, filter []string) map[string]interface{} { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3c81bad76..6d1ea733e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -74,6 +74,7 @@ steps: p4: p4_step px4: px4_step p5: p5_step + dependentParameter: dependentValue stages: stage1: p5: p5_stage @@ -82,7 +83,7 @@ stages: ` filters := StepFilters{ General: []string{"p0", "p1", "p2", "p3", "p4"}, - Steps: []string{"p0", "p1", "p2", "p3", "p4", "p5"}, + Steps: []string{"p0", "p1", "p2", "p3", "p4", "p5", "dependentParameter", "pd1", "dependentValue", "pd2"}, Stages: []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6"}, Parameters: []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7"}, Env: []string{"p0", "p1", "p2", "p3", "p4", "p5"}, @@ -97,6 +98,8 @@ steps: p1: p1_step_default px1: px1_step_default p2: p2_step_default + dependentValue: + pd1: pd1_dependent_default ` defaults2 := `general: @@ -112,20 +115,49 @@ steps: defaults := []io.ReadCloser{ioutil.NopCloser(strings.NewReader(defaults1)), ioutil.NopCloser(strings.NewReader(defaults2))} myConfig := ioutil.NopCloser(strings.NewReader(testConfig)) - stepConfig, err := c.GetStepConfig(flags, paramJSON, myConfig, defaults, filters, []StepParameters{}, "stage1", "step1") + + parameterMetadata := []StepParameters{ + { + Name: "pd1", + Scope: []string{"STEPS"}, + Conditions: []Condition{ + { + Params: []Param{ + {Name: "dependentParameter", Value: "dependentValue"}, + }, + }, + }, + }, + { + Name: "pd2", + Default: "pd2_metadata_default", + Scope: []string{"STEPS"}, + Conditions: []Condition{ + { + Params: []Param{ + {Name: "dependentParameter", Value: "dependentValue"}, + }, + }, + }, + }, + } + + stepConfig, err := c.GetStepConfig(flags, paramJSON, myConfig, defaults, filters, parameterMetadata, "stage1", "step1") assert.Equal(t, nil, err, "error occured but none expected") t.Run("Config", func(t *testing.T) { expected := map[string]string{ - "p0": "p0_general_default", - "p1": "p1_step_default", - "p2": "p2_general_default", - "p3": "p3_general", - "p4": "p4_step", - "p5": "p5_stage", - "p6": "p6_param", - "p7": "p7_flag", + "p0": "p0_general_default", + "p1": "p1_step_default", + "p2": "p2_general_default", + "p3": "p3_general", + "p4": "p4_step", + "p5": "p5_stage", + "p6": "p6_param", + "p7": "p7_flag", + "pd1": "pd1_dependent_default", + "pd2": "pd2_metadata_default", } for k, v := range expected { t.Run(k, func(t *testing.T) { From 8587452a3c1c6da46fd03200d51e7cc18859d1dd Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Tue, 5 Nov 2019 17:29:05 +0100 Subject: [PATCH 105/141] Update resources/metadata/githubrelease.yaml Co-Authored-By: Christopher Fenner --- resources/metadata/githubrelease.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/metadata/githubrelease.yaml b/resources/metadata/githubrelease.yaml index 8ed7b4d23..6e8cefa55 100644 --- a/resources/metadata/githubrelease.yaml +++ b/resources/metadata/githubrelease.yaml @@ -90,7 +90,7 @@ spec: type: string mandatory: true - name: serverUrl - aliases: + aliases: - name: githubServerUrl description: 'GitHub server url for end-user access.' scope: From 48bfc10956586f925ce2ade724ba0462417a9f10 Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Tue, 5 Nov 2019 17:33:00 +0100 Subject: [PATCH 106/141] Address PR feedback --- cmd/githubPublishRelease.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go index 3d18f8e71..1963bda85 100644 --- a/cmd/githubPublishRelease.go +++ b/cmd/githubPublishRelease.go @@ -112,8 +112,8 @@ func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGi log.Entry().WithError(err).Error("Failed to get GitHub issues.") } - prTexts := []string{"\n**List of closed pull-requests since last release**"} - issueTexts := []string{"\n**List of closed issues since last release**"} + prTexts := []string{"**List of closed pull-requests since last release**"} + issueTexts := []string{"**List of closed issues since last release**"} for _, issue := range ghIssues { if issue.IsPullRequest() && !isExcluded(issue, myGithubPublishReleaseOptions.ExcludeLabels) { @@ -126,11 +126,11 @@ func getClosedIssuesText(ctx context.Context, publishedAt github.Timestamp, myGi } if len(prTexts) > 1 { - closedIssuesText += strings.Join(prTexts, "\n") + "\n" + closedIssuesText += "\n" + strings.Join(prTexts, "\n") + "\n" } if len(issueTexts) > 1 { - closedIssuesText += strings.Join(issueTexts, "\n") + "\n" + closedIssuesText += "\n" + strings.Join(issueTexts, "\n") + "\n" } return closedIssuesText } From 58128be9701e06b2b8c6df0633efa010b7edbe23 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 5 Nov 2019 21:51:44 +0100 Subject: [PATCH 107/141] apply formatter (#950) --- cmd/piper_test.go | 5 ++--- cmd/version_test.go | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmd/piper_test.go b/cmd/piper_test.go index c824d9a67..72bbbfce6 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" ) - type execMockRunner struct { dir []string calls []execCall @@ -42,11 +41,11 @@ func (m *execMockRunner) RunExecutable(e string, p ...string) error { return nil } -func(m *shellMockRunner) Dir(d string) { +func (m *shellMockRunner) Dir(d string) { m.dir = d } -func(m *shellMockRunner) RunShell(s string, c string) error { +func (m *shellMockRunner) RunShell(s string, c string) error { m.calls = append(m.calls, c) return nil } diff --git a/cmd/version_test.go b/cmd/version_test.go index 5135bb4b5..a3daffb87 100644 --- a/cmd/version_test.go +++ b/cmd/version_test.go @@ -1,11 +1,11 @@ package cmd import ( - "testing" - "os" "bytes" - "io" "github.com/stretchr/testify/assert" + "io" + "os" + "testing" ) func TestVersion(t *testing.T) { @@ -17,7 +17,6 @@ func TestVersion(t *testing.T) { assert.Contains(t, result, "tag: \"\"") }) - t.Run("versionAndTagSet", func(t *testing.T) { result := runVersionCommand(t, "16bafe", "v1.2.3") @@ -29,9 +28,9 @@ func TestVersion(t *testing.T) { func runVersionCommand(t *testing.T, commitID, tag string) string { orig := os.Stdout - defer func() {os.Stdout = orig}() + defer func() { os.Stdout = orig }() - r,w,e := os.Pipe() + r, w, e := os.Pipe() if e != nil { t.Error("Cannot setup pipes.") } @@ -41,8 +40,12 @@ func runVersionCommand(t *testing.T, commitID, tag string) string { // // needs to be set in the free wild by the build process: // go build -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT} -X github.com/SAP/jenkins-library/cmd.GitTag=${GIT_TAG}" - if len(commitID) > 0 { GitCommit = commitID; } - if len(tag) > 0 { GitTag = tag } + if len(commitID) > 0 { + GitCommit = commitID + } + if len(tag) > 0 { + GitTag = tag + } defer func() { GitCommit = ""; GitTag = "" }() // // @@ -58,4 +61,4 @@ func runVersionCommand(t *testing.T, commitID, tag string) string { var buf bytes.Buffer io.Copy(&buf, r) return buf.String() -} \ No newline at end of file +} From 5c87d4775ca355245f99177da242f702098507c0 Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Wed, 6 Nov 2019 09:05:07 +0100 Subject: [PATCH 108/141] Ensure asset update for latest release only --- cmd/githubPublishRelease.go | 3 ++- cmd/githubPublishRelease_test.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go index 1963bda85..aad18dd4d 100644 --- a/cmd/githubPublishRelease.go +++ b/cmd/githubPublishRelease.go @@ -58,7 +58,8 @@ func runGithubPublishRelease(ctx context.Context, myGithubPublishReleaseOptions publishedAt = lastRelease.GetPublishedAt() log.Entry().Debugf("Previous GitHub release published: '%v'", publishedAt) - if myGithubPublishReleaseOptions.UpdateAsset { + //updating assets only supported on latest release + if myGithubPublishReleaseOptions.UpdateAsset && myGithubPublishReleaseOptions.Version == "latest" { return uploadReleaseAsset(ctx, lastRelease.GetID(), myGithubPublishReleaseOptions, ghRepoClient) } diff --git a/cmd/githubPublishRelease_test.go b/cmd/githubPublishRelease_test.go index 9008d3e8c..95aafc493 100644 --- a/cmd/githubPublishRelease_test.go +++ b/cmd/githubPublishRelease_test.go @@ -171,6 +171,7 @@ func TestRunGithubPublishRelease(t *testing.T) { myGithubPublishReleaseOptions := githubPublishReleaseOptions{ UpdateAsset: true, AssetPath: filepath.Join("testdata", t.Name()+"_test.txt"), + Version: "latest", } err := runGithubPublishRelease(ctx, &myGithubPublishReleaseOptions, &ghRepoClient, &ghIssueClient) From deb965e2b418ba5f72a64b0f4ee5a118091bad0a Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Wed, 6 Nov 2019 09:12:50 +0100 Subject: [PATCH 109/141] Fix CodeClimate finding with generator update --- cmd/githubPublishRelease.go | 2 +- cmd/githubPublishRelease_generated.go | 4 ++-- pkg/generator/step-metadata.go | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/githubPublishRelease.go b/cmd/githubPublishRelease.go index aad18dd4d..a7f70e471 100644 --- a/cmd/githubPublishRelease.go +++ b/cmd/githubPublishRelease.go @@ -28,7 +28,7 @@ type githubIssueClient interface { } func githubPublishRelease(myGithubPublishReleaseOptions githubPublishReleaseOptions) error { - ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.Token, myGithubPublishReleaseOptions.ApiURL, myGithubPublishReleaseOptions.UploadURL) + ctx, client, err := piperGithub.NewClient(myGithubPublishReleaseOptions.Token, myGithubPublishReleaseOptions.APIURL, myGithubPublishReleaseOptions.UploadURL) if err != nil { log.Entry().WithError(err).Fatal("Failed to get GitHub client.") } diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go index 9ba10ffae..faddfaf2e 100644 --- a/cmd/githubPublishRelease_generated.go +++ b/cmd/githubPublishRelease_generated.go @@ -14,7 +14,7 @@ type githubPublishReleaseOptions struct { AssetPath string `json:"assetPath,omitempty"` Commitish string `json:"commitish,omitempty"` ExcludeLabels []string `json:"excludeLabels,omitempty"` - ApiURL string `json:"apiUrl,omitempty"` + APIURL string `json:"apiUrl,omitempty"` Owner string `json:"owner,omitempty"` Repository string `json:"repository,omitempty"` ServerURL string `json:"serverUrl,omitempty"` @@ -65,7 +65,7 @@ func addGithubPublishReleaseFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&myGithubPublishReleaseOptions.AssetPath, "assetPath", os.Getenv("PIPER_assetPath"), "Path to a release asset which should be uploaded to the list of release assets.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Commitish, "commitish", "master", "Target git commitish for the release") cmd.Flags().StringSliceVar(&myGithubPublishReleaseOptions.ExcludeLabels, "excludeLabels", []string{}, "Allows to exclude issues with dedicated list of labels.") - cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ApiURL, "apiUrl", "https://api.github.com", "Set the GitHub API url.") + cmd.Flags().StringVar(&myGithubPublishReleaseOptions.APIURL, "apiUrl", "https://api.github.com", "Set the GitHub API url.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Owner, "owner", os.Getenv("PIPER_owner"), "Set the GitHub organization.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.Repository, "repository", os.Getenv("PIPER_repository"), "Set the GitHub repository.") cmd.Flags().StringVar(&myGithubPublishReleaseOptions.ServerURL, "serverUrl", "https://github.com", "GitHub server url for end-user access.") diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 3a056e09d..fa46fe994 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -295,6 +295,7 @@ func longName(long string) string { func golangName(name string) string { properName := strings.Replace(name, "Api", "API", -1) + properName = strings.Replace(name, "api", "API", -1) properName = strings.Replace(properName, "Url", "URL", -1) properName = strings.Replace(properName, "Id", "ID", -1) properName = strings.Replace(properName, "Json", "JSON", -1) From a456282d6a3f3ed5a6f70615ec6ebcc94287df11 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Wed, 6 Nov 2019 10:28:15 +0100 Subject: [PATCH 110/141] fix: getting config should also work in case there is no project config (#951) --- cmd/getConfig.go | 17 ++++++++++++++--- pkg/config/config.go | 7 ++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cmd/getConfig.go b/cmd/getConfig.go index bbc4cc799..edef89618 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -53,9 +53,12 @@ func generateConfig() error { return errors.Wrap(err, "metadata: read failed") } - customConfig, err := configOptions.openFile(generalConfig.customConfig) - if err != nil { - return errors.Wrap(err, "config: open failed") + var customConfig io.ReadCloser + if fileExists(generalConfig.customConfig) { + customConfig, err = configOptions.openFile(generalConfig.customConfig) + if err != nil { + return errors.Wrap(err, "config: open failed") + } } defaultConfig, paramFilter, err := defaultsAndFilters(&metadata) @@ -118,3 +121,11 @@ func defaultsAndFilters(metadata *config.StepData) ([]io.ReadCloser, config.Step //ToDo: retrieve default values from metadata return nil, metadata.GetParameterFilters(), nil } + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 622e9dffb..2ef7f62e2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -81,12 +81,9 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri var stepConfig StepConfig var d PipelineDefaults - if err := c.ReadConfig(configuration); err != nil { - switch err.(type) { - case *ParseError: + if configuration != nil { + if err := c.ReadConfig(configuration); err != nil { return StepConfig{}, errors.Wrap(err, "failed to parse custom pipeline configuration") - default: - //ignoring unavailability of config file since considered optional } } c.ApplyAliasConfig(parameters, filters, stageName, stepName) From 57540d31279d1224fb57804edd376807cf50fb77 Mon Sep 17 00:00:00 2001 From: OliverNocon Date: Wed, 6 Nov 2019 10:32:02 +0100 Subject: [PATCH 111/141] Fix bug in replacement function --- pkg/generator/step-metadata.go | 2 +- pkg/generator/step-metadata_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index fa46fe994..fabf35c74 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -295,7 +295,7 @@ func longName(long string) string { func golangName(name string) string { properName := strings.Replace(name, "Api", "API", -1) - properName = strings.Replace(name, "api", "API", -1) + properName = strings.Replace(properName, "api", "API", -1) properName = strings.Replace(properName, "Url", "URL", -1) properName = strings.Replace(properName, "Id", "ID", -1) properName = strings.Replace(properName, "Json", "JSON", -1) diff --git a/pkg/generator/step-metadata_test.go b/pkg/generator/step-metadata_test.go index 2af975ca7..0285b0360 100644 --- a/pkg/generator/step-metadata_test.go +++ b/pkg/generator/step-metadata_test.go @@ -203,6 +203,7 @@ func TestGolangName(t *testing.T) { expected string }{ {input: "testApi", expected: "TestAPI"}, + {input: "apiTest", expected: "APITest"}, {input: "testUrl", expected: "TestURL"}, {input: "testId", expected: "TestID"}, {input: "testJson", expected: "TestJSON"}, From de31cde9b82e80334a9789ce6722827d2fe29893 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 6 Nov 2019 11:28:10 +0100 Subject: [PATCH 112/141] Add PiperGoUtils for downloading piper binary (#928) * Add PiperGoUtils for downloading piper binary PiperGoUtils provide the link between a Jenkins library step and the library step execution running in a go binary. It makes sure that an adequate binary is available. * fix CodeClimate finding * Remove Delimiter and add download resilience. --- .gitignore | 4 +- go.sum | 1 + src/com/sap/piper/JenkinsUtils.groovy | 19 +++ src/com/sap/piper/PiperGoUtils.groovy | 63 +++++++++ .../com/sap/piper/JenkinsUtilsTest.groovy | 18 +++ .../com/sap/piper/PiperGoUtilsTest.groovy | 129 ++++++++++++++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/com/sap/piper/PiperGoUtils.groovy create mode 100644 test/groovy/com/sap/piper/PiperGoUtilsTest.groovy diff --git a/.gitignore b/.gitignore index 8225066ba..98be8170b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,5 @@ documentation/docs-gen consumer-test/**/workspace *.code-workspace -piper -piper.exe +/piper +/piper.exe diff --git a/go.sum b/go.sum index 138315660..23b2c9ede 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= diff --git a/src/com/sap/piper/JenkinsUtils.groovy b/src/com/sap/piper/JenkinsUtils.groovy index 58296f7a9..6b43ffa77 100644 --- a/src/com/sap/piper/JenkinsUtils.groovy +++ b/src/com/sap/piper/JenkinsUtils.groovy @@ -7,6 +7,7 @@ import hudson.tasks.junit.TestResultAction import jenkins.model.Jenkins import org.apache.commons.io.IOUtils +import org.jenkinsci.plugins.workflow.libs.LibrariesAction import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException @API @@ -108,3 +109,21 @@ String getIssueCommentTriggerAction() { def getJobStartedByUserId() { return getRawBuild().getCause(hudson.model.Cause.UserIdCause.class)?.getUserId() } + +@NonCPS +def getLibrariesInfo() { + def libraries = [] + def build = getRawBuild() + def libs = build.getAction(LibrariesAction.class).getLibraries() + + for (def i = 0; i < libs.size(); i++) { + Map lib = [:] + + lib['name'] = libs[i].name + lib['version'] = libs[i].version + lib['trusted'] = libs[i].trusted + libraries.add(lib) + } + + return libraries +} diff --git a/src/com/sap/piper/PiperGoUtils.groovy b/src/com/sap/piper/PiperGoUtils.groovy new file mode 100644 index 000000000..c575433cb --- /dev/null +++ b/src/com/sap/piper/PiperGoUtils.groovy @@ -0,0 +1,63 @@ +package com.sap.piper + +class PiperGoUtils implements Serializable { + + private static Script steps + private static Utils utils + + PiperGoUtils(Script steps) { + this.steps = steps + this.utils = new Utils() + } + + PiperGoUtils(Script steps, Utils utils) { + this.steps = steps + this.utils = utils + } + + void unstashPiperBin() { + + if (utils.unstash('piper-bin').size() > 0) return + + def libraries = getLibrariesInfo() + String version + libraries.each {lib -> + if (lib.name == 'piper-lib-os') { + version = lib.version + } + } + + def fallbackUrl = 'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master' + def piperBinUrl = (version == 'master') ? fallbackUrl : "https://github.com/SAP/jenkins-library/releases/tag/${version}" + + boolean downloaded = downloadGoBinary(piperBinUrl) + if (!downloaded) { + //Inform that no Piper binary is available for used library branch + steps.echo ("Not able to download go binary of Piper for version ${version}") + //Fallback to master version & throw error in case this fails + steps.retry(5) { + if (!downloadGoBinary(fallbackUrl)) { + steps.sleep(2) + steps.error("Download of Piper go binary failed.") + } + } + + } + utils.stashWithMessage('piper-bin', 'failed to stash piper binary', 'piper') + } + + List getLibrariesInfo() { + return new JenkinsUtils().getLibrariesInfo() + } + + private boolean downloadGoBinary(url) { + + def httpStatus = steps.sh(returnStdout: true, script: "curl --insecure --silent --location --write-out '%{http_code}' --output ./piper '${url}'") + + if (httpStatus == '200') { + steps.sh(script: 'chmod +x ./piper') + return true + } + return false + } +} diff --git a/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy b/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy index b915bc82e..b07fe41b8 100644 --- a/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy +++ b/test/groovy/com/sap/piper/JenkinsUtilsTest.groovy @@ -71,6 +71,16 @@ class JenkinsUtilsTest extends BasePiperTest { return triggerCause } } + def getAction(type) { + return new Object() { + def getLibraries() { + return [ + [name: 'lib1', version: '1', trusted: true], + [name: 'lib2', version: '2', trusted: false], + ] + } + } + } } LibraryLoadingTestExecutionListener.prepareObjectInterceptors(rawBuildMock) @@ -130,4 +140,12 @@ class JenkinsUtilsTest extends BasePiperTest { userId = null assertThat(jenkinsUtils.getJobStartedByUserId(), isEmptyOrNullString()) } + + @Test + void testGetLibrariesInfo() { + def libs + libs = jenkinsUtils.getLibrariesInfo() + assertThat(libs[0], is([name: 'lib1', version: '1', trusted: true])) + assertThat(libs[1], is([name: 'lib2', version: '2', trusted: false])) + } } diff --git a/test/groovy/com/sap/piper/PiperGoUtilsTest.groovy b/test/groovy/com/sap/piper/PiperGoUtilsTest.groovy new file mode 100644 index 000000000..92f64b370 --- /dev/null +++ b/test/groovy/com/sap/piper/PiperGoUtilsTest.groovy @@ -0,0 +1,129 @@ +package com.sap.piper + +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.JenkinsShellCallRule +import util.Rules + +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.is +import static org.junit.Assert.assertThat + +class PiperGoUtilsTest extends BasePiperTest { + + public ExpectedException exception = ExpectedException.none() + public JenkinsShellCallRule shellCallRule = new JenkinsShellCallRule(this) + public JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + + @Rule + public RuleChain ruleChain = Rules.getCommonRules(this) + .around(shellCallRule) + .around(exception) + .around(loggingRule) + + @Before + void init() { + helper.registerAllowedMethod("retry", [Integer, Closure], null) + } + + @Test + void testUnstashPiperBinAvailable() { + + def piperBinStash = 'piper-bin' + + // this mocks utils.unstash + helper.registerAllowedMethod("unstash", [String.class], { stashFileName -> + if (stashFileName != piperBinStash) { + return [] + } + return [piperBinStash] + }) + + def piperGoUtils = new PiperGoUtils(nullScript, utils) + + piperGoUtils.unstashPiperBin() + } + + + @Test + void testUnstashPiperBinMaster() { + + def piperGoUtils = new PiperGoUtils(nullScript, utils) + piperGoUtils.metaClass.getLibrariesInfo = {-> return [[name: 'piper-lib-os', version: 'master']]} + + // this mocks utils.unstash - mimic stash not existing + helper.registerAllowedMethod("unstash", [String.class], { stashFileName -> + return [] + }) + + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master\'', '200') + + piperGoUtils.unstashPiperBin() + assertThat(shellCallRule.shell.size(), is(2)) + assertThat(shellCallRule.shell[0].toString(), is('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master\'')) + assertThat(shellCallRule.shell[1].toString(), is('chmod +x ./piper')) + } + + @Test + void testUnstashPiperBinNonMaster() { + + def piperGoUtils = new PiperGoUtils(nullScript, utils) + piperGoUtils.metaClass.getLibrariesInfo = {-> return [[name: 'piper-lib-os', version: 'testTag']]} + + // this mocks utils.unstash - mimic stash not existing + helper.registerAllowedMethod("unstash", [String.class], { stashFileName -> + return [] + }) + + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/tag/testTag\'', '200') + + piperGoUtils.unstashPiperBin() + assertThat(shellCallRule.shell.size(), is(2)) + assertThat(shellCallRule.shell[0].toString(), is('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/tag/testTag\'')) + assertThat(shellCallRule.shell[1].toString(), is('chmod +x ./piper')) + } + + @Test + void testUnstashPiperBinFallback() { + + def piperGoUtils = new PiperGoUtils(nullScript, utils) + piperGoUtils.metaClass.getLibrariesInfo = {-> return [[name: 'piper-lib-os', version: 'notAvailable']]} + + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/tag/notAvailable\'', '404') + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master\'', '200') + + // this mocks utils.unstash - mimic stash not existing + helper.registerAllowedMethod("unstash", [String.class], { stashFileName -> + return [] + }) + + piperGoUtils.unstashPiperBin() + assertThat(shellCallRule.shell.size(), is(3)) + assertThat(shellCallRule.shell[0].toString(), is('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/tag/notAvailable\'')) + assertThat(shellCallRule.shell[1].toString(), is('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master\'')) + assertThat(shellCallRule.shell[2].toString(), is('chmod +x ./piper')) + } + + @Test + void testDownloadFailed() { + def piperGoUtils = new PiperGoUtils(nullScript, utils) + piperGoUtils.metaClass.getLibrariesInfo = {-> return [[name: 'piper-lib-os', version: 'notAvailable']]} + + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/tag/notAvailable\'', '404') + shellCallRule.setReturnValue('curl --insecure --silent --location --write-out \'%{http_code}\' --output ./piper \'https://github.com/SAP/jenkins-library/releases/latest/download/piper_master\'', '500') + + helper.registerAllowedMethod("unstash", [String.class], { stashFileName -> + return [] + }) + + exception.expectMessage(containsString('Download of Piper go binary failed')) + piperGoUtils.unstashPiperBin() + } +} + + From bb230d3b9bf6f63fa76fe38b3563479f452d3528 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 6 Nov 2019 14:07:41 +0100 Subject: [PATCH 113/141] Export general configuration (#956) Allow for package external access to general configuration. Use-case: re-use individual steps --- cmd/getConfig.go | 8 +++--- cmd/githubPublishRelease_generated.go | 2 +- cmd/karmaExecuteTests_generated.go | 2 +- cmd/piper.go | 25 ++++++++++--------- cmd/piper_test.go | 14 +++++------ cmd/version_generated.go | 2 +- go.mod | 2 +- pkg/generator/step-metadata.go | 2 +- .../step_code_generated.golden | 2 +- 9 files changed, 30 insertions(+), 29 deletions(-) diff --git a/cmd/getConfig.go b/cmd/getConfig.go index 7b7d909aa..24b092559 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -54,8 +54,8 @@ func generateConfig() error { } var customConfig io.ReadCloser - if fileExists(generalConfig.customConfig) { - customConfig, err = configOptions.openFile(generalConfig.customConfig) + if fileExists(GeneralConfig.customConfig) { + customConfig, err = configOptions.openFile(GeneralConfig.customConfig) if err != nil { return errors.Wrap(err, "config: open failed") } @@ -66,7 +66,7 @@ func generateConfig() error { return errors.Wrap(err, "defaults: retrieving step defaults failed") } - for _, f := range generalConfig.defaultConfig { + for _, f := range GeneralConfig.defaultConfig { fc, err := configOptions.openFile(f) if err != nil { return errors.Wrapf(err, "config: getting defaults failed: '%v'", f) @@ -81,7 +81,7 @@ func generateConfig() error { params = metadata.Spec.Inputs.Parameters } - stepConfig, err = myConfig.GetStepConfig(flags, generalConfig.parametersJSON, customConfig, defaultConfig, paramFilter, params, generalConfig.stageName, configOptions.stepName) + stepConfig, err = myConfig.GetStepConfig(flags, GeneralConfig.parametersJSON, customConfig, defaultConfig, paramFilter, params, GeneralConfig.stageName, configOptions.stepName) if err != nil { return errors.Wrap(err, "getting step config failed") } diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go index faddfaf2e..c49052617 100644 --- a/cmd/githubPublishRelease_generated.go +++ b/cmd/githubPublishRelease_generated.go @@ -47,7 +47,7 @@ The result looks like ![Example release](../images/githubRelease.png)`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("githubPublishRelease") - log.SetVerbose(generalConfig.verbose) + log.SetVerbose(GeneralConfig.verbose) return PrepareConfig(cmd, &metadata, "githubPublishRelease", &myGithubPublishReleaseOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/karmaExecuteTests_generated.go b/cmd/karmaExecuteTests_generated.go index db6e8fe23..a7a78086a 100644 --- a/cmd/karmaExecuteTests_generated.go +++ b/cmd/karmaExecuteTests_generated.go @@ -34,7 +34,7 @@ In the Docker network, the containers can be referenced by the values provided i In a Kubernetes environment, the containers both need to be referenced with ` + "`" + `localhost` + "`" + `.`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("karmaExecuteTests") - log.SetVerbose(generalConfig.verbose) + log.SetVerbose(GeneralConfig.verbose) return PrepareConfig(cmd, &metadata, "karmaExecuteTests", &myKarmaExecuteTestsOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/piper.go b/cmd/piper.go index 7994ee8f6..c51a4ce04 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -33,7 +33,8 @@ It contains many steps which can be used within CI/CD systems as well as directl //ToDo: respect stageName to also come from parametersJSON -> first env.STAGE_NAME, second: parametersJSON, third: flag } -var generalConfig generalConfigOptions +// GeneralConfig contains global configuration flags for piper binary +var GeneralConfig generalConfigOptions // Execute is the starting point of the piper command line tool func Execute() { @@ -52,12 +53,12 @@ func Execute() { func addRootFlags(rootCmd *cobra.Command) { - rootCmd.PersistentFlags().StringVar(&generalConfig.customConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") - rootCmd.PersistentFlags().StringSliceVar(&generalConfig.defaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") - rootCmd.PersistentFlags().StringVar(&generalConfig.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") - rootCmd.PersistentFlags().StringVar(&generalConfig.stageName, "stageName", os.Getenv("STAGE_NAME"), "Name of the stage for which configuration should be included") - rootCmd.PersistentFlags().StringVar(&generalConfig.stepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") - rootCmd.PersistentFlags().BoolVarP(&generalConfig.verbose, "verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.customConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") + rootCmd.PersistentFlags().StringSliceVar(&GeneralConfig.defaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.stageName, "stageName", os.Getenv("STAGE_NAME"), "Name of the stage for which configuration should be included") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.stepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") + rootCmd.PersistentFlags().BoolVarP(&GeneralConfig.verbose, "verbose", "v", false, "verbose output") } @@ -71,23 +72,23 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin var myConfig config.Config var stepConfig config.StepConfig - if len(generalConfig.stepConfigJSON) != 0 { + if len(GeneralConfig.stepConfigJSON) != 0 { // ignore config & defaults in favor of passed stepConfigJSON - stepConfig = config.GetStepConfigWithJSON(flagValues, generalConfig.stepConfigJSON, filters) + stepConfig = config.GetStepConfigWithJSON(flagValues, GeneralConfig.stepConfigJSON, filters) } else { // use config & defaults //accept that config file and defaults cannot be loaded since both are not mandatory here - customConfig, _ := openFile(generalConfig.customConfig) + customConfig, _ := openFile(GeneralConfig.customConfig) var defaultConfig []io.ReadCloser - for _, f := range generalConfig.defaultConfig { + for _, f := range GeneralConfig.defaultConfig { //ToDo: support also https as source fc, _ := openFile(f) defaultConfig = append(defaultConfig, fc) } var err error - stepConfig, err = myConfig.GetStepConfig(flagValues, generalConfig.parametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, generalConfig.stageName, stepName) + stepConfig, err = myConfig.GetStepConfig(flagValues, GeneralConfig.parametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, GeneralConfig.stageName, stepName) if err != nil { return errors.Wrap(err, "retrieving step configuration failed") } diff --git a/cmd/piper_test.go b/cmd/piper_test.go index 72bbbfce6..0679710dc 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -81,14 +81,14 @@ func TestAddRootFlags(t *testing.T) { } func TestPrepareConfig(t *testing.T) { - defaultsBak := generalConfig.defaultConfig - generalConfig.defaultConfig = []string{"testDefaults.yml"} - defer func() { generalConfig.defaultConfig = defaultsBak }() + defaultsBak := GeneralConfig.defaultConfig + GeneralConfig.defaultConfig = []string{"testDefaults.yml"} + defer func() { GeneralConfig.defaultConfig = defaultsBak }() t.Run("using stepConfigJSON", func(t *testing.T) { - stepConfigJSONBak := generalConfig.stepConfigJSON - generalConfig.stepConfigJSON = `{"testParam": "testValueJSON"}` - defer func() { generalConfig.stepConfigJSON = stepConfigJSONBak }() + stepConfigJSONBak := GeneralConfig.stepConfigJSON + GeneralConfig.stepConfigJSON = `{"testParam": "testValueJSON"}` + defer func() { GeneralConfig.stepConfigJSON = stepConfigJSONBak }() testOptions := stepOptions{} var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} testCmd.Flags().StringVar(&testOptions.TestParam, "testParam", "", "test usage") @@ -136,7 +136,7 @@ func TestPrepareConfig(t *testing.T) { }) t.Run("error case", func(t *testing.T) { - generalConfig.defaultConfig = []string{"testDefaultsInvalid.yml"} + GeneralConfig.defaultConfig = []string{"testDefaultsInvalid.yml"} testOptions := stepOptions{} var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} metadata := config.StepData{} diff --git a/cmd/version_generated.go b/cmd/version_generated.go index d0d8c2152..431d398a3 100644 --- a/cmd/version_generated.go +++ b/cmd/version_generated.go @@ -21,7 +21,7 @@ func VersionCommand() *cobra.Command { Long: `Writes the commit hash and the tag (if any) to stdout and exits with 0.`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("version") - log.SetVerbose(generalConfig.verbose) + log.SetVerbose(GeneralConfig.verbose) return PrepareConfig(cmd, &metadata, "version", &myVersionOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index c678f17e6..49bd0f9f3 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.13 require ( github.com/ghodss/yaml v1.0.0 - github.com/google/go-github/v28 v28.1.1 github.com/google/go-cmp v0.3.1 + github.com/google/go-github/v28 v28.1.1 github.com/pkg/errors v0.8.1 github.com/sirupsen/logrus v1.4.2 github.com/spf13/cobra v0.0.5 diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index fabf35c74..16ea11388 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -54,7 +54,7 @@ func {{.CobraCmdFuncName}}() *cobra.Command { Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("{{ .StepName }}") - log.SetVerbose(generalConfig.verbose) + log.SetVerbose(GeneralConfig.verbose) return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden index 7a244bbae..141d2e13d 100644 --- a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -26,7 +26,7 @@ func TestStepCommand() *cobra.Command { Long: `Long Test description`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("testStep") - log.SetVerbose(generalConfig.verbose) + log.SetVerbose(GeneralConfig.verbose) return PrepareConfig(cmd, &metadata, "testStep", &myTestStepOptions, openPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { From a04489cd35e6920c7f956b5e288ad46ead594aa7 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 6 Nov 2019 15:37:14 +0100 Subject: [PATCH 114/141] Add step spinnakerTriggerPipeline (#793) --- resources/default_pipeline_environment.yml | 4 + .../SpinnakerTriggerPipelineTest.groovy | 202 ++++++++++++++++++ vars/spinnakerTriggerPipeline.groovy | 174 +++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 test/groovy/SpinnakerTriggerPipelineTest.groovy create mode 100644 vars/spinnakerTriggerPipeline.groovy 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}" + } + + } +} From ede322c8bbc86a05d6507dfb87b29f9ab8054452 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 6 Nov 2019 16:22:50 +0100 Subject: [PATCH 115/141] Export general configuration - part 2 (#957) * Export general configuration - part 2 First part in #956 missed to export the elements of the struct ... * Add comment for exported struct * Add function for opening config files --- cmd/getConfig.go | 10 ++-- cmd/githubPublishRelease_generated.go | 4 +- cmd/karmaExecuteTests_generated.go | 4 +- cmd/piper.go | 46 ++++++++++--------- cmd/piper_test.go | 14 +++--- cmd/version_generated.go | 4 +- pkg/generator/step-metadata.go | 4 +- .../step_code_generated.golden | 4 +- 8 files changed, 46 insertions(+), 44 deletions(-) diff --git a/cmd/getConfig.go b/cmd/getConfig.go index 24b092559..3850b3dd2 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -24,7 +24,7 @@ var configOptions configCommandOptions // ConfigCommand is the entry command for loading the configuration of a pipeline step func ConfigCommand() *cobra.Command { - configOptions.openFile = openPiperFile + configOptions.openFile = OpenPiperFile var createConfigCmd = &cobra.Command{ Use: "getConfig", Short: "Loads the project 'Piper' configuration respecting defaults and parameters.", @@ -54,8 +54,8 @@ func generateConfig() error { } var customConfig io.ReadCloser - if fileExists(GeneralConfig.customConfig) { - customConfig, err = configOptions.openFile(GeneralConfig.customConfig) + if fileExists(GeneralConfig.CustomConfig) { + customConfig, err = configOptions.openFile(GeneralConfig.CustomConfig) if err != nil { return errors.Wrap(err, "config: open failed") } @@ -66,7 +66,7 @@ func generateConfig() error { return errors.Wrap(err, "defaults: retrieving step defaults failed") } - for _, f := range GeneralConfig.defaultConfig { + for _, f := range GeneralConfig.DefaultConfig { fc, err := configOptions.openFile(f) if err != nil { return errors.Wrapf(err, "config: getting defaults failed: '%v'", f) @@ -81,7 +81,7 @@ func generateConfig() error { params = metadata.Spec.Inputs.Parameters } - stepConfig, err = myConfig.GetStepConfig(flags, GeneralConfig.parametersJSON, customConfig, defaultConfig, paramFilter, params, GeneralConfig.stageName, configOptions.stepName) + stepConfig, err = myConfig.GetStepConfig(flags, GeneralConfig.ParametersJSON, customConfig, defaultConfig, paramFilter, params, GeneralConfig.StageName, configOptions.stepName) if err != nil { return errors.Wrap(err, "getting step config failed") } diff --git a/cmd/githubPublishRelease_generated.go b/cmd/githubPublishRelease_generated.go index c49052617..975e19b37 100644 --- a/cmd/githubPublishRelease_generated.go +++ b/cmd/githubPublishRelease_generated.go @@ -47,8 +47,8 @@ The result looks like ![Example release](../images/githubRelease.png)`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("githubPublishRelease") - log.SetVerbose(GeneralConfig.verbose) - return PrepareConfig(cmd, &metadata, "githubPublishRelease", &myGithubPublishReleaseOptions, openPiperFile) + log.SetVerbose(GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "githubPublishRelease", &myGithubPublishReleaseOptions, OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return githubPublishRelease(myGithubPublishReleaseOptions) diff --git a/cmd/karmaExecuteTests_generated.go b/cmd/karmaExecuteTests_generated.go index a7a78086a..f3053b505 100644 --- a/cmd/karmaExecuteTests_generated.go +++ b/cmd/karmaExecuteTests_generated.go @@ -34,8 +34,8 @@ In the Docker network, the containers can be referenced by the values provided i In a Kubernetes environment, the containers both need to be referenced with ` + "`" + `localhost` + "`" + `.`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("karmaExecuteTests") - log.SetVerbose(GeneralConfig.verbose) - return PrepareConfig(cmd, &metadata, "karmaExecuteTests", &myKarmaExecuteTestsOptions, openPiperFile) + log.SetVerbose(GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "karmaExecuteTests", &myKarmaExecuteTestsOptions, OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return karmaExecuteTests(myKarmaExecuteTestsOptions) diff --git a/cmd/piper.go b/cmd/piper.go index c51a4ce04..9f2995c11 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -12,15 +12,16 @@ import ( "github.com/spf13/cobra" ) -type generalConfigOptions struct { - customConfig string - defaultConfig []string //ordered list of Piper default configurations. Can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' - parametersJSON string - stageName string - stepConfigJSON string - stepMetadata string //metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' - stepName string - verbose bool +// GeneralConfigOptions contains all global configuration options for piper binary +type GeneralConfigOptions struct { + CustomConfig string + DefaultConfig []string //ordered list of Piper default configurations. Can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + ParametersJSON string + StageName string + StepConfigJSON string + StepMetadata string //metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + StepName string + Verbose bool } var rootCmd = &cobra.Command{ @@ -34,7 +35,7 @@ It contains many steps which can be used within CI/CD systems as well as directl } // GeneralConfig contains global configuration flags for piper binary -var GeneralConfig generalConfigOptions +var GeneralConfig GeneralConfigOptions // Execute is the starting point of the piper command line tool func Execute() { @@ -53,12 +54,12 @@ func Execute() { func addRootFlags(rootCmd *cobra.Command) { - rootCmd.PersistentFlags().StringVar(&GeneralConfig.customConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") - rootCmd.PersistentFlags().StringSliceVar(&GeneralConfig.defaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") - rootCmd.PersistentFlags().StringVar(&GeneralConfig.parametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") - rootCmd.PersistentFlags().StringVar(&GeneralConfig.stageName, "stageName", os.Getenv("STAGE_NAME"), "Name of the stage for which configuration should be included") - rootCmd.PersistentFlags().StringVar(&GeneralConfig.stepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") - rootCmd.PersistentFlags().BoolVarP(&GeneralConfig.verbose, "verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.CustomConfig, "customConfig", ".pipeline/config.yml", "Path to the pipeline configuration file") + rootCmd.PersistentFlags().StringSliceVar(&GeneralConfig.DefaultConfig, "defaultConfig", nil, "Default configurations, passed as path to yaml file") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.ParametersJSON, "parametersJSON", os.Getenv("PIPER_parametersJSON"), "Parameters to be considered in JSON format") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.StageName, "stageName", os.Getenv("STAGE_NAME"), "Name of the stage for which configuration should be included") + rootCmd.PersistentFlags().StringVar(&GeneralConfig.StepConfigJSON, "stepConfigJSON", os.Getenv("PIPER_stepConfigJSON"), "Step configuration in JSON format") + rootCmd.PersistentFlags().BoolVarP(&GeneralConfig.Verbose, "verbose", "v", false, "verbose output") } @@ -72,23 +73,23 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin var myConfig config.Config var stepConfig config.StepConfig - if len(GeneralConfig.stepConfigJSON) != 0 { + if len(GeneralConfig.StepConfigJSON) != 0 { // ignore config & defaults in favor of passed stepConfigJSON - stepConfig = config.GetStepConfigWithJSON(flagValues, GeneralConfig.stepConfigJSON, filters) + stepConfig = config.GetStepConfigWithJSON(flagValues, GeneralConfig.StepConfigJSON, filters) } else { // use config & defaults //accept that config file and defaults cannot be loaded since both are not mandatory here - customConfig, _ := openFile(GeneralConfig.customConfig) + customConfig, _ := openFile(GeneralConfig.CustomConfig) var defaultConfig []io.ReadCloser - for _, f := range GeneralConfig.defaultConfig { + for _, f := range GeneralConfig.DefaultConfig { //ToDo: support also https as source fc, _ := openFile(f) defaultConfig = append(defaultConfig, fc) } var err error - stepConfig, err = myConfig.GetStepConfig(flagValues, GeneralConfig.parametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, GeneralConfig.stageName, stepName) + stepConfig, err = myConfig.GetStepConfig(flagValues, GeneralConfig.ParametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, GeneralConfig.StageName, stepName) if err != nil { return errors.Wrap(err, "retrieving step configuration failed") } @@ -102,7 +103,8 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin return nil } -func openPiperFile(name string) (io.ReadCloser, error) { +// OpenPiperFile provides functionality to retrieve configuration via file or http +func OpenPiperFile(name string) (io.ReadCloser, error) { //ToDo: support also https as source if !strings.HasPrefix(name, "http") { return os.Open(name) diff --git a/cmd/piper_test.go b/cmd/piper_test.go index 0679710dc..ea80fc696 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -81,14 +81,14 @@ func TestAddRootFlags(t *testing.T) { } func TestPrepareConfig(t *testing.T) { - defaultsBak := GeneralConfig.defaultConfig - GeneralConfig.defaultConfig = []string{"testDefaults.yml"} - defer func() { GeneralConfig.defaultConfig = defaultsBak }() + defaultsBak := GeneralConfig.DefaultConfig + GeneralConfig.DefaultConfig = []string{"testDefaults.yml"} + defer func() { GeneralConfig.DefaultConfig = defaultsBak }() t.Run("using stepConfigJSON", func(t *testing.T) { - stepConfigJSONBak := GeneralConfig.stepConfigJSON - GeneralConfig.stepConfigJSON = `{"testParam": "testValueJSON"}` - defer func() { GeneralConfig.stepConfigJSON = stepConfigJSONBak }() + stepConfigJSONBak := GeneralConfig.StepConfigJSON + GeneralConfig.StepConfigJSON = `{"testParam": "testValueJSON"}` + defer func() { GeneralConfig.StepConfigJSON = stepConfigJSONBak }() testOptions := stepOptions{} var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} testCmd.Flags().StringVar(&testOptions.TestParam, "testParam", "", "test usage") @@ -136,7 +136,7 @@ func TestPrepareConfig(t *testing.T) { }) t.Run("error case", func(t *testing.T) { - GeneralConfig.defaultConfig = []string{"testDefaultsInvalid.yml"} + GeneralConfig.DefaultConfig = []string{"testDefaultsInvalid.yml"} testOptions := stepOptions{} var testCmd = &cobra.Command{Use: "test", Short: "This is just a test"} metadata := config.StepData{} diff --git a/cmd/version_generated.go b/cmd/version_generated.go index 431d398a3..7859a46d0 100644 --- a/cmd/version_generated.go +++ b/cmd/version_generated.go @@ -21,8 +21,8 @@ func VersionCommand() *cobra.Command { Long: `Writes the commit hash and the tag (if any) to stdout and exits with 0.`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("version") - log.SetVerbose(GeneralConfig.verbose) - return PrepareConfig(cmd, &metadata, "version", &myVersionOptions, openPiperFile) + log.SetVerbose(GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "version", &myVersionOptions, OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return version(myVersionOptions) diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 16ea11388..797400f84 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -54,8 +54,8 @@ func {{.CobraCmdFuncName}}() *cobra.Command { Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("{{ .StepName }}") - log.SetVerbose(GeneralConfig.verbose) - return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, openPiperFile) + log.SetVerbose(GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return {{.StepName}}(my{{ .StepName | title }}Options) diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden index 141d2e13d..3f3d2d760 100644 --- a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -26,8 +26,8 @@ func TestStepCommand() *cobra.Command { Long: `Long Test description`, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("testStep") - log.SetVerbose(GeneralConfig.verbose) - return PrepareConfig(cmd, &metadata, "testStep", &myTestStepOptions, openPiperFile) + log.SetVerbose(GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "testStep", &myTestStepOptions, OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return testStep(myTestStepOptions) From 7c5a8a73bc5ec8528940319fe4867d4a616faa5d Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 7 Nov 2019 08:17:42 +0100 Subject: [PATCH 116/141] Helper for fileExists (#954) Since we need it at several places (next use case will be step xsDeploy) we should have a helper for that. --- cmd/getConfig.go | 11 ++--------- pkg/piperutils/FileUtils.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 pkg/piperutils/FileUtils.go diff --git a/cmd/getConfig.go b/cmd/getConfig.go index 3850b3dd2..4049467fc 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -6,6 +6,7 @@ import ( "os" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/piperutils" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -54,7 +55,7 @@ func generateConfig() error { } var customConfig io.ReadCloser - if fileExists(GeneralConfig.CustomConfig) { + if piperutils.FileExists(GeneralConfig.CustomConfig) { customConfig, err = configOptions.openFile(GeneralConfig.CustomConfig) if err != nil { return errors.Wrap(err, "config: open failed") @@ -119,11 +120,3 @@ func defaultsAndFilters(metadata *config.StepData) ([]io.ReadCloser, config.Step //ToDo: retrieve default values from metadata return nil, metadata.GetParameterFilters(), nil } - -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} diff --git a/pkg/piperutils/FileUtils.go b/pkg/piperutils/FileUtils.go new file mode 100644 index 000000000..bcb63d8bd --- /dev/null +++ b/pkg/piperutils/FileUtils.go @@ -0,0 +1,14 @@ +package piperutils + +import ( + "os" +) + +// FileExists ... +func FileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} From f4aa5fc377c6bd988b728c2b63ff4c0261136ef0 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 7 Nov 2019 09:02:11 +0100 Subject: [PATCH 117/141] Let the mocks fail if error is provided from a test (#940) There was some command parsing with failure in case it started with fail. That is IMO less transparent. Now we prepare more explicit with a failure from outside. This enables us to prepare an error like we expect it in the free wild. --- cmd/karmaExecuteTests_test.go | 7 ++++--- cmd/piper_test.go | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cmd/karmaExecuteTests_test.go b/cmd/karmaExecuteTests_test.go index 057e8c625..b8132397a 100644 --- a/cmd/karmaExecuteTests_test.go +++ b/cmd/karmaExecuteTests_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "testing" "github.com/SAP/jenkins-library/pkg/log" @@ -28,7 +29,7 @@ func TestRunKarma(t *testing.T) { opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "fail install test", RunCommand: "npm run test"} - e := execMockRunner{} + e := execMockRunner{shouldFailWith: errors.New("error case")} runKarma(opts, &e) assert.True(t, hasFailed, "expected command to exit with fatal") }) @@ -37,9 +38,9 @@ func TestRunKarma(t *testing.T) { var hasFailed bool log.Entry().Logger.ExitFunc = func(int) { hasFailed = true } - opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "fail run test"} + opts := karmaExecuteTestsOptions{ModulePath: "./test", InstallCommand: "npm install test", RunCommand: "npm run test"} - e := execMockRunner{} + e := execMockRunner{shouldFailWith: errors.New("error case")} runKarma(opts, &e) assert.True(t, hasFailed, "expected command to exit with fatal") }) diff --git a/cmd/piper_test.go b/cmd/piper_test.go index ea80fc696..35e2ee5c5 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -1,7 +1,6 @@ package cmd import ( - "fmt" "io" "io/ioutil" "strings" @@ -16,6 +15,7 @@ import ( type execMockRunner struct { dir []string calls []execCall + shouldFailWith error } type execCall struct { @@ -26,6 +26,7 @@ type execCall struct { type shellMockRunner struct { dir string calls []string + shouldFailWith error } func (m *execMockRunner) Dir(d string) { @@ -33,8 +34,8 @@ func (m *execMockRunner) Dir(d string) { } func (m *execMockRunner) RunExecutable(e string, p ...string) error { - if e == "fail" { - return fmt.Errorf("error case") + if m.shouldFailWith != nil { + return m.shouldFailWith } exec := execCall{exec: e, params: p} m.calls = append(m.calls, exec) @@ -46,6 +47,11 @@ func (m *shellMockRunner) Dir(d string) { } func (m *shellMockRunner) RunShell(s string, c string) error { + + if m.shouldFailWith != nil { + return m.shouldFailWith + } + m.calls = append(m.calls, c) return nil } From 06f63bc5dead836b185397c6e2fd7ff09b0a496a Mon Sep 17 00:00:00 2001 From: Maximilian Lenkeit Date: Thu, 7 Nov 2019 11:02:27 +0100 Subject: [PATCH 118/141] remove opa5 stash (#897) * remove opa5 stash * remove OPA5 test cases * remove reference to opa5 stash --- documentation/docs/steps/pipelineStashFiles.md | 1 - resources/default_pipeline_environment.yml | 2 -- test/groovy/PipelineStashFilesBeforeBuildTest.groovy | 2 -- 3 files changed, 5 deletions(-) diff --git a/documentation/docs/steps/pipelineStashFiles.md b/documentation/docs/steps/pipelineStashFiles.md index 82ec7d01e..8dce41685 100644 --- a/documentation/docs/steps/pipelineStashFiles.md +++ b/documentation/docs/steps/pipelineStashFiles.md @@ -19,7 +19,6 @@ The step is stashing files before and after the build. This is due to the fact, |classFiles|no| |includes: `**/target/classes/**/*.class, **/target/test-classes/**/*.class`
excludes: `''`| |deployDescriptor|no| |includes: `**/manifest*.y*ml, **/*.mtaext.y*ml, **/*.mtaext, **/xs-app.json, helm/**, *.y*ml`
exclude: `''`| |git|no| |includes: `**/gitmetadata/**`
exludes: `''`| -|opa5|no|OPA5 is enabled|includes: `**/*.*`
excludes: `''`| |opensourceConfiguration|no| |includes: `**/srcclr.yml, **/vulas-custom.properties, **/.nsprc, **/.retireignore, **/.retireignore.json, **/.snyk`
excludes: `''`| |pipelineConfigAndTests|no| |includes: `.pipeline/*.*`
excludes: `''`| |securityDescriptor|no| |includes: `**/xs-security.json`
exludes: `''`| diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 1324fbf6d..8db70a17b 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -443,7 +443,6 @@ steps: buildDescriptor: '**/pom.xml, **/.mvn/**, **/assembly.xml, **/.swagger-codegen-ignore, **/package.json, **/requirements.txt, **/setup.py, **/mta*.y*ml, **/.npmrc, Dockerfile, .hadolint.yaml, **/VERSION, **/version.txt, **/Gopkg.*, **/dub.json, **/dub.sdl, **/build.sbt, **/sbtDescriptor.json, **/project/*, **/ui5.yaml, **/ui5.yml' deployDescriptor: '**/manifest*.y*ml, **/*.mtaext.y*ml, **/*.mtaext, **/xs-app.json, helm/**, *.y*ml' git: '.git/**' - opa5: '**/*.*' opensourceConfiguration: '**/srcclr.yml, **/vulas-custom.properties, **/.nsprc, **/.retireignore, **/.retireignore.json, **/.snyk, **/wss-unified-agent.config, **/vendor/**/*' pipelineConfigAndTests: '.pipeline/**' securityDescriptor: '**/xs-security.json' @@ -452,7 +451,6 @@ steps: buildDescriptor: '**/node_modules/**/package.json' deployDescriptor: '' git: '' - opa5: '' opensourceConfiguration: '' pipelineConfigAndTests: '' securityDescriptor: '' diff --git a/test/groovy/PipelineStashFilesBeforeBuildTest.groovy b/test/groovy/PipelineStashFilesBeforeBuildTest.groovy index 29d3b9fbd..84339428a 100644 --- a/test/groovy/PipelineStashFilesBeforeBuildTest.groovy +++ b/test/groovy/PipelineStashFilesBeforeBuildTest.groovy @@ -30,7 +30,6 @@ class PipelineStashFilesBeforeBuildTest extends BasePiperTest { assertThat(loggingRule.log, containsString('Stash content: buildDescriptor')) assertThat(loggingRule.log, containsString('Stash content: deployDescriptor')) assertThat(loggingRule.log, containsString('Stash content: git')) - assertThat(loggingRule.log, containsString('Stash content: opa5')) assertThat(loggingRule.log, containsString('Stash content: opensourceConfiguration')) assertThat(loggingRule.log, containsString('Stash content: pipelineConfigAndTests')) assertThat(loggingRule.log, containsString('Stash content: securityDescriptor')) @@ -46,7 +45,6 @@ class PipelineStashFilesBeforeBuildTest extends BasePiperTest { assertThat(loggingRule.log, containsString('Stash content: buildDescriptor')) assertThat(loggingRule.log, containsString('Stash content: deployDescriptor')) assertThat(loggingRule.log, containsString('Stash content: git')) - assertThat(loggingRule.log, containsString('Stash content: opa5')) assertThat(loggingRule.log, containsString('Stash content: opensourceConfiguration')) assertThat(loggingRule.log, containsString('Stash content: pipelineConfigAndTests')) assertThat(loggingRule.log, containsString('Stash content: securityDescriptor')) From 2a6c075001ad9fb3293166d9e6c5d71807ce7a05 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 7 Nov 2019 15:37:27 +0100 Subject: [PATCH 119/141] streamline git url retrieval (#850) * Streamline url parsing in piperPipelineStageInit * Remove .git appendix only once * Improve the regex for parsing urls now the colon for the port is contained in the port group. This increases the understandability of the regex. * Improve the regex for parsing the urls again now the leading slash of the path is contained in the path group. This increases the understandability of the regex. --- vars/piperPipelineStageInit.groovy | 39 ++++++++++++------------------ 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/vars/piperPipelineStageInit.groovy b/vars/piperPipelineStageInit.groovy index 2a682ec73..049311b2e 100644 --- a/vars/piperPipelineStageInit.groovy +++ b/vars/piperPipelineStageInit.groovy @@ -133,42 +133,35 @@ private void initStashConfiguration (script, config) { private void setGitUrlsOnCommonPipelineEnvironment(script, String gitUrl) { - def gitPath = '' - if (gitUrl.startsWith('http')) { - def httpPattern = /(https?):\/\/([^:\/]+)(?:[:\d\/]*)(.*)/ - def gitMatcher = gitUrl =~ httpPattern - if (!gitMatcher.hasGroup() && gitMatcher.groupCount() != 3) return - script.commonPipelineEnvironment.setGitSshUrl("git@${gitMatcher[0][2]}:${gitMatcher[0][3]}") - gitPath = gitMatcher[0][3] + def urlMatcher = gitUrl =~ /^((http|https|git|ssh):\/\/)?((.*)@)?([^:\/]+)(:([\d]*))?(\/?(.*))$/ + + def protocol = urlMatcher[0][2] + def auth = urlMatcher[0][4] + def host = urlMatcher[0][5] + def port = urlMatcher[0][7] + def path = urlMatcher[0][9] + + if (protocol in ['http', 'https']) { + script.commonPipelineEnvironment.setGitSshUrl("git@${host}:${path}") script.commonPipelineEnvironment.setGitHttpsUrl(gitUrl) - } else if (gitUrl.startsWith('ssh')) { - //(.*)@([^:\/]*)(?:[:\d\/]*)(.*) - def httpPattern = /(.*)@([^:\/]*)(?:[:\d\/]*)(.*)/ - def gitMatcher = gitUrl =~ httpPattern - if (!gitMatcher.hasGroup() && gitMatcher.groupCount() != 3) return + } else if (protocol in [ null, 'ssh', 'git']) { script.commonPipelineEnvironment.setGitSshUrl(gitUrl) - script.commonPipelineEnvironment.setGitHttpsUrl("https://${gitMatcher[0][2]}/${gitMatcher[0][3]}") - gitPath = gitMatcher[0][3] - } - else if (gitUrl.indexOf('@') > 0) { - script.commonPipelineEnvironment.setGitSshUrl(gitUrl) - gitPath = gitUrl.split(':')[1] - script.commonPipelineEnvironment.setGitHttpsUrl("https://${(gitUrl.split('@')[1]).replace(':', '/')}") + script.commonPipelineEnvironment.setGitHttpsUrl("https://${host}/${path}") } - List gitPathParts = gitPath.split('/') + List gitPathParts = path.replaceAll('.git', '').split('/') def gitFolder = 'N/A' def gitRepo = 'N/A' switch (gitPathParts.size()) { case 1: - gitRepo = gitPathParts[0].replaceAll('.git', '') + gitRepo = gitPathParts[0] break case 2: gitFolder = gitPathParts[0] - gitRepo = gitPathParts[1].replaceAll('.git', '') + gitRepo = gitPathParts[1] break case { it > 3 }: - gitRepo = gitPathParts[gitPathParts.size()-1].replaceAll('.git', '') + gitRepo = gitPathParts[gitPathParts.size()-1] gitPathParts.remove(gitPathParts.size()-1) gitFolder = gitPathParts.join('/') break From 2e0bf3ac34463f19769599c671074aadfeabb4df Mon Sep 17 00:00:00 2001 From: Daniel Mieg <56156797+DanielMieg@users.noreply.github.com> Date: Thu, 7 Nov 2019 15:40:45 +0100 Subject: [PATCH 120/141] Add step to pull repository to ABAP in SAP Cloud Platform (#907) --- .../docs/steps/abapEnvironmentPullGitRepo.md | 29 +++ documentation/mkdocs.yml | 1 + .../AbapEnvironmentPullGitRepoTest.groovy | 178 +++++++++++++++ vars/abapEnvironmentPullGitRepo.groovy | 214 ++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 documentation/docs/steps/abapEnvironmentPullGitRepo.md create mode 100644 test/groovy/AbapEnvironmentPullGitRepoTest.groovy create mode 100644 vars/abapEnvironmentPullGitRepo.groovy diff --git a/documentation/docs/steps/abapEnvironmentPullGitRepo.md b/documentation/docs/steps/abapEnvironmentPullGitRepo.md new file mode 100644 index 000000000..aa6f7503f --- /dev/null +++ b/documentation/docs/steps/abapEnvironmentPullGitRepo.md @@ -0,0 +1,29 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## Prerequisites + +* A SAP Cloud Platform ABAP Environment system is available. +* On this system, a [Communication User](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/0377adea0401467f939827242c1f4014.html), a [Communication System](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/1bfe32ae08074b7186e375ab425fb114.html) and a [Communication Arrangement](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/a0771f6765f54e1c8193ad8582a32edb.html) is setup for the Communication Scenario "SAP Cloud Platform ABAP Environment - Software Component Test Integration (SAP_COM_0510)". +* It is recommended to use the Jenkins credentials configuration for user and password handling and wrap the call to "abapEnvironmentPullGitRepo" with the Jenkins Step "withCredentials". + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} + +## Example + +```groovy +withCredentials([usernamePassword(credentialsId: 'myCredentialsId', usernameVariable: 'USER', passwordVariable: 'PASSWORD')]) { + abapEnvironmentPullGitRepo( + host : ' 1234-abcd-5678-efgh-ijk.abap.eu10.hana.ondemand.com', + repositoryName : '/DMO/GIT_REPOSITORY', + username : "\$USER", + password : "\$PASSWORD", + script : this + ) +} +``` diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 7b63bf062..b5da8229d 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -28,6 +28,7 @@ nav: - 'Integrate SAP Cloud Platform Transport Management Into Your CI/CD Pipeline': scenarios/TMS_Extension.md - Extensibility: extensibility.md - 'Library steps': + - abapEnvironmentPullGitRepo: steps/abapEnvironmentPullGitRepo.md - artifactSetVersion: steps/artifactSetVersion.md - batsExecuteTests: steps/batsExecuteTests.md - buildExecute: steps/buildExecute.md diff --git a/test/groovy/AbapEnvironmentPullGitRepoTest.groovy b/test/groovy/AbapEnvironmentPullGitRepoTest.groovy new file mode 100644 index 000000000..244de7418 --- /dev/null +++ b/test/groovy/AbapEnvironmentPullGitRepoTest.groovy @@ -0,0 +1,178 @@ +import java.util.Map +import static org.hamcrest.Matchers.hasItem +import static org.junit.Assert.assertThat + +import org.hamcrest.Matchers +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.equalTo +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.JenkinsCredentialsRule +import util.JenkinsStepRule +import util.JenkinsLoggingRule +import util.JenkinsReadYamlRule +import util.JenkinsShellCallRule +import util.Rules + +import hudson.AbortException + +public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { + + private ExpectedException thrown = new ExpectedException() + private JenkinsStepRule stepRule = new JenkinsStepRule(this) + private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) + private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + + @Rule + public RuleChain ruleChain = Rules.getCommonRules(this) + .around(new JenkinsReadYamlRule(this)) + .around(thrown) + .around(stepRule) + .around(loggingRule) + .around(shellRule) + + @Before + public void setup() { + } + + @Test + public void pullSuccessful() { + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*x-csrf-token: fetch.*/, null ) + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*POST.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "R", "status_descr" : "RUNNING" }}/) + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*https:\/\/example\.com.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "S", "status_descr" : "SUCCESS" }}/) + + helper.registerAllowedMethod("readFile", [String.class], { + /HTTP\/1.1 200 OK + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: application\/json; charset=utf-8 + x-csrf-token: TOKEN/ + }) + + loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: RUNNING") + loggingRule.expect("[abapEnvironmentPullGitRepo] Entity URI: https://example.com/URI") + loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: SUCCESS") + + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + + assertThat(shellRule.shell[0], containsString(/#!\/bin\/bash curl -I -X GET https:\/\/example.com\/sap\/opu\/odata\/sap\/MANAGE_GIT_REPOSITORY\/Pull -H 'Authorization: Basic dXNlcjpwYXNzd29yZA==' -H 'Accept: application\/json' -H 'x-csrf-token: fetch' -D headerFileAuth-1.txt/)) + assertThat(shellRule.shell[1], containsString(/#!\/bin\/bash curl -X POST "https:\/\/example.com\/sap\/opu\/odata\/sap\/MANAGE_GIT_REPOSITORY\/Pull" -H 'Authorization: Basic dXNlcjpwYXNzd29yZA==' -H 'Accept: application\/json' -H 'Content-Type: application\/json' -H 'x-csrf-token: TOKEN' --cookie headerFileAuth-1.txt -D headerFilePost-1.txt -d '{ "sc_name": "Z_DEMO_DM" }'/)) + assertThat(shellRule.shell[2], containsString(/#!\/bin\/bash curl -X GET "https:\/\/example.com\/URI" -H 'Authorization: Basic dXNlcjpwYXNzd29yZA==' -H 'Accept: application\/json' -D headerFilePoll-1.txt/)) + assertThat(shellRule.shell[3], containsString(/#!\/bin\/bash rm -f headerFileAuth-1.txt headerFilePost-1.txt headerFilePoll-1.txt/)) + } + + @Test + public void pullFailsWhilePolling() { + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*x-csrf-token: fetch.*/, "TOKEN") + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*POST.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "R", "status_descr" : "RUNNING" }}/) + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*https:\/\/example\.com.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "E", "status_descr" : "ERROR" }}/) + + helper.registerAllowedMethod("readFile", [String.class], { + /HTTP\/1.1 200 OK + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: application\/json; charset=utf-8/ + }) + + loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: RUNNING") + loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: ERROR") + + thrown.expect(Exception) + thrown.expectMessage("[abapEnvironmentPullGitRepo] Pull Failed") + + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + + } + + @Test + public void pullFailsWithPostRequest() { + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*x-csrf-token: fetch.*/, "TOKEN") + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*POST.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "E", "status_descr" : "ERROR" }}/) + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*https:\/\/example\.com.*/, /{"d" : { "__metadata" : { "uri" : "https:\/\/example.com\/URI" } , "status" : "E", "status_descr" : "ERROR" }}/) + + helper.registerAllowedMethod("readFile", [String.class], { + /HTTP\/1.1 200 OK + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: application\/json; charset=utf-8/ + }) + + loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: ERROR") + + thrown.expect(Exception) + thrown.expectMessage("[abapEnvironmentPullGitRepo] Pull Failed") + + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + + } + + @Test + public void pullWithErrorResponse() { + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*x-csrf-token: fetch.*/, "TOKEN") + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*POST.*/, /{"error" : { "message" : { "lang" : "en", "value": "text" } }}/) + + helper.registerAllowedMethod("readFile", [String.class], { + /HTTP\/1.1 200 OK + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: application\/json; charset=utf-8/ + }) + + thrown.expect(Exception) + thrown.expectMessage("[abapEnvironmentPullGitRepo] Error: text") + + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + + } + + @Test + public void connectionFails() { + shellRule.setReturnValue(JenkinsShellCallRule.Type.REGEX, /.*x-csrf-token: fetch.*/, null) + + helper.registerAllowedMethod("readFile", [String.class], { + /HTTP\/1.1 401 Unauthorized + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: application\/json; charset=utf-8/ + }) + + thrown.expect(Exception) + thrown.expectMessage("[abapEnvironmentPullGitRepo] Error: 401 Unauthorized") + + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + + } + + @Test + public void checkRepositoryProvided() { + thrown.expect(IllegalArgumentException) + thrown.expectMessage("Repository / Software Component not provided") + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', username: 'user', password: 'password') + } + + @Test + public void checkHostProvided() { + thrown.expect(IllegalArgumentException) + thrown.expectMessage("Host not provided") + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, repositoryName: 'REPO', username: 'user', password: 'password') + } + + @Test + public void testHttpHeader() { + + String header = /HTTP\/1.1 401 Unauthorized + set-cookie: sap-usercontext=sap-client=100; path=\/ + content-type: text\/html; charset=utf-8 + content-length: 9321 + sap-system: Y11 + x-csrf-token: TOKEN + www-authenticate: Basic realm="SAP NetWeaver Application Server [Y11\/100][alias]" + sap-server: true + sap-perf-fesrec: 72927.000000/ + + HttpHeaderProperties httpHeader = new HttpHeaderProperties(header) + assertThat(httpHeader.statusCode, equalTo(401)) + assertThat(httpHeader.statusMessage, containsString("Unauthorized")) + assertThat(httpHeader.xCsrfToken, containsString("TOKEN")) + } +} diff --git a/vars/abapEnvironmentPullGitRepo.groovy b/vars/abapEnvironmentPullGitRepo.groovy new file mode 100644 index 000000000..fa5d7f3d9 --- /dev/null +++ b/vars/abapEnvironmentPullGitRepo.groovy @@ -0,0 +1,214 @@ +import static com.sap.piper.Prerequisites.checkScript +import com.sap.piper.ConfigurationHelper +import com.sap.piper.GenerateDocumentation +import com.sap.piper.JenkinsUtils +import com.sap.piper.Utils +import groovy.json.JsonSlurper +import hudson.AbortException +import groovy.transform.Field +import org.jenkinsci.plugins.workflow.steps.FlowInterruptedException +import java.util.UUID + +@Field def STEP_NAME = getClass().getName() +@Field Set GENERAL_CONFIG_KEYS = [ + /** + * Specifies the host address of the SAP Cloud Platform ABAP Environment system + */ + 'host', + /** + * Specifies the name of the Repository (Software Component) on the SAP Cloud Platform ABAP Environment system + */ + 'repositoryName' +] +@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ + /** + * Specifies the communication user of the communication scenario SAP_COM_0510 + */ + 'username', + /** + * Specifies the password of the communication user + */ + 'password' +]) +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS +/** + * Pulls a Repository (Software Component) to a SAP Cloud Platform ABAP Environment system. + * + * !!! note "Git Repository and Software Component" + * In SAP Cloud Platform ABAP Environment Git repositories are wrapped in Software Components (which are managed in the App "Manage Software Components") + * Currently, those two names are used synonymous. + * !!! note "User and Password" + * In the future, we want to support the user / password creation via the create-service-key funcion of cloud foundry. + * For this case, it is not possible to use the usual pattern with Jenkins Credentials. + */ +@GenerateDocumentation +void call(Map parameters = [:]) { + + handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters, failOnError: true) { + + def script = checkScript(this, parameters) ?: this + + // In the future, we want to support the user / password creation via the create-service-key funcion of cloud foundry. + // For this case, it is not possible to use the usual pattern with Jenkins Credentials. + Map configuration = ConfigurationHelper.newInstance(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName ?: env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixin(parameters, PARAMETER_KEYS) + .collectValidationFailures() + .withMandatoryProperty('host', 'Host not provided') + .withMandatoryProperty('repositoryName', 'Repository / Software Component not provided') + .withMandatoryProperty('username') + .withMandatoryProperty('password') + .use() + + String usernameColonPassword = configuration.username + ":" + configuration.password + String authToken = usernameColonPassword.bytes.encodeBase64().toString() + String urlString = 'https://' + configuration.host + '/sap/opu/odata/sap/MANAGE_GIT_REPOSITORY/Pull' + echo "[${STEP_NAME}] General Parameters: URL = \"${urlString}\", repositoryName = \"${configuration.repositoryName}\"" + HeaderFiles headerFiles = new HeaderFiles() + + try { + String urlPullEntity = triggerPull(configuration, urlString, authToken, headerFiles) + if (urlPullEntity != null) { + String finalStatus = pollPullStatus(urlPullEntity, authToken, headerFiles) + if (finalStatus != 'S') { + error "[${STEP_NAME}] Pull Failed" + } + } else { + error "[${STEP_NAME}] Pull Failed" + } + } finally { + workspaceCleanup(headerFiles) + } + } +} + +private String triggerPull(Map configuration, String url, String authToken, HeaderFiles headerFiles) { + + String entityUri = null + + def xCsrfTokenScript = """#!/bin/bash + curl -I -X GET ${url} \ + -H 'Authorization: Basic ${authToken}' \ + -H 'Accept: application/json' \ + -H 'x-csrf-token: fetch' \ + -D ${headerFiles.authFile} \ + """ + + sh ( script : xCsrfTokenScript, returnStdout: true ) + + HttpHeaderProperties headerProperties = new HttpHeaderProperties(readFile(headerFiles.authFile)) + checkRequestStatus(headerProperties) + + def scriptPull = """#!/bin/bash + curl -X POST \"${url}\" \ + -H 'Authorization: Basic ${authToken}' \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -H 'x-csrf-token: ${headerProperties.xCsrfToken}' \ + --cookie ${headerFiles.authFile} \ + -D ${headerFiles.postFile} \ + -d '{ \"sc_name\": \"${configuration.repositoryName}\" }' + """ + def response = sh ( + script : scriptPull, + returnStdout: true ) + + checkRequestStatus(new HttpHeaderProperties(readFile(headerFiles.postFile))) + + JsonSlurper slurper = new JsonSlurper() + Map responseJson = slurper.parseText(response) + if (responseJson.d != null) { + entityUri = responseJson.d.__metadata.uri.toString() + echo "[${STEP_NAME}] Pull Status: ${responseJson.d.status_descr.toString()}" + } else { + error "[${STEP_NAME}] Error: ${responseJson?.error?.message?.value?.toString()?:'No message available'}" + } + + echo "[${STEP_NAME}] Entity URI: ${entityUri}" + return entityUri + +} + +private String pollPullStatus(String url, String authToken, HeaderFiles headerFiles) { + + String headerFile = "headerPoll.txt" + String status = "R"; + while(status == "R") { + + Thread.sleep(5000) + + def pollScript = """#!/bin/bash + curl -X GET "${url}" \ + -H 'Authorization: Basic ${authToken}' \ + -H 'Accept: application/json' \ + -D ${headerFiles.pollFile} + """ + def pollResponse = sh ( + script : pollScript, + returnStdout: true ) + + checkRequestStatus(new HttpHeaderProperties(readFile(headerFiles.pollFile))) + + JsonSlurper slurper = new JsonSlurper() + Map pollResponseJson = slurper.parseText(pollResponse) + if (pollResponseJson.d != null) { + status = pollResponseJson.d.status.toString() + } else { + error "[${STEP_NAME}] Error: ${pollResponseJson?.error?.message?.value?.toString()?:'No message available'}" + } + echo "[${STEP_NAME}] Pull Status: ${pollResponseJson.d.status_descr.toString()}" + } + return status +} + +private void checkRequestStatus(HttpHeaderProperties httpHeader) { + if (httpHeader.statusCode == 400) { + echo "[${STEP_NAME}] Info: ${httpHeader.statusCode} ${httpHeader.statusMessage}" + } else if (httpHeader.statusCode > 201) { + error "[${STEP_NAME}] Error: ${httpHeader.statusCode} ${httpHeader.statusMessage}" + } +} + +private void workspaceCleanup(HeaderFiles headerFiles) { + String cleanupScript = """#!/bin/bash + rm -f ${headerFiles.authFile} ${headerFiles.postFile} ${headerFiles.pollFile} + """ + sh ( script : cleanupScript ) +} + +public class HttpHeaderProperties{ + Integer statusCode + String statusMessage + String xCsrfToken + + HttpHeaderProperties(String header) { + def statusCodeRegex = header =~ /(?<=HTTP\/1.[0-9]\s)[0-9]{3}(?=\s)/ + if (statusCodeRegex.find()) { + statusCode = statusCodeRegex[0].toInteger() + } + def statusMessageRegex = header =~ /(?<=HTTP\/1.[0-9]\s[0-9]{3}\s).*/ + if (statusMessageRegex.find()) { + statusMessage = statusMessageRegex[0] + } + def xCsrfTokenRegex = header =~ /(?<=x-csrf-token:\s).*/ + if (xCsrfTokenRegex.find()) { + xCsrfToken = xCsrfTokenRegex[0] + } + } +} + +public class HeaderFiles{ + + String authFile + String postFile + String pollFile + + HeaderFiles() { + String uuid = UUID.randomUUID().toString() + this.authFile = "headerFileAuth-${uuid}.txt" + this.postFile = "headerFilePost-${uuid}.txt" + this.pollFile = "headerFilePoll-${uuid}.txt" + } +} From fedfb7491024e3021bd80513d8c8db30eb734305 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Thu, 7 Nov 2019 15:42:22 +0100 Subject: [PATCH 121/141] Export step generator (#958) * Export step generator * Fix CodeClimate finding --- pkg/generator/step-metadata.go | 21 ++++++++++++------- pkg/generator/step-metadata_test.go | 4 ++-- .../step_code_generated.golden | 1 + 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 797400f84..161b85372 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -17,6 +17,7 @@ import ( type stepInfo struct { CobraCmdFuncName string CreateCmdVar string + ExportPrefix string FlagsFunc string Long string Metadata []config.StepParameters @@ -32,6 +33,7 @@ const stepGoTemplate = `package cmd import ( {{if .OSImport}}"os"{{end}} + {{if .ExportPrefix}}{{ .ExportPrefix }} "github.com/SAP/jenkins-library/cmd"{{end}} "github.com/SAP/jenkins-library/pkg/config" "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" @@ -54,8 +56,8 @@ func {{.CobraCmdFuncName}}() *cobra.Command { Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("{{ .StepName }}") - log.SetVerbose(GeneralConfig.Verbose) - return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, OpenPiperFile) + log.SetVerbose({{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return {{.StepName}}(my{{ .StepName | title }}Options) @@ -118,10 +120,10 @@ func main() { metadataPath := "./resources/metadata" - metadataFiles, err := metadataFiles(metadataPath) + metadataFiles, err := MetadataFiles(metadataPath) checkError(err) - err = processMetaFiles(metadataFiles, openMetaFile, fileWriter) + err = ProcessMetaFiles(metadataFiles, openMetaFile, fileWriter, "") checkError(err) cmd := exec.Command("go", "fmt", "./cmd") @@ -130,7 +132,8 @@ func main() { } -func processMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCloser, error), writeFile func(filename string, data []byte, perm os.FileMode) error) error { +// ProcessMetaFiles generates step coding based on step configuration provided in yaml files +func ProcessMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCloser, error), writeFile func(filename string, data []byte, perm os.FileMode) error, exportPrefix string) error { for key := range metadataFiles { var stepData config.StepData @@ -152,7 +155,7 @@ func processMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCl osImport, err = setDefaultParameters(&stepData) checkError(err) - myStepInfo := getStepInfo(&stepData, osImport) + myStepInfo := getStepInfo(&stepData, osImport, exportPrefix) step := stepTemplate(myStepInfo) err = writeFile(fmt.Sprintf("cmd/%v_generated.go", stepData.Metadata.Name), step, 0644) @@ -214,7 +217,7 @@ func setDefaultParameters(stepData *config.StepData) (bool, error) { return osImportRequired, nil } -func getStepInfo(stepData *config.StepData, osImport bool) stepInfo { +func getStepInfo(stepData *config.StepData, osImport bool, exportPrefix string) stepInfo { return stepInfo{ StepName: stepData.Metadata.Name, CobraCmdFuncName: fmt.Sprintf("%vCommand", strings.Title(stepData.Metadata.Name)), @@ -224,6 +227,7 @@ func getStepInfo(stepData *config.StepData, osImport bool) stepInfo { Metadata: stepData.Spec.Inputs.Parameters, FlagsFunc: fmt.Sprintf("add%vFlags", strings.Title(stepData.Metadata.Name)), OSImport: osImport, + ExportPrefix: exportPrefix, } } @@ -234,7 +238,8 @@ func checkError(err error) { } } -func metadataFiles(sourceDirectory string) ([]string, error) { +// MetadataFiles provides a list of all step metadata files +func MetadataFiles(sourceDirectory string) ([]string, error) { var metadataFiles []string diff --git a/pkg/generator/step-metadata_test.go b/pkg/generator/step-metadata_test.go index 0285b0360..b8a5c1a6c 100644 --- a/pkg/generator/step-metadata_test.go +++ b/pkg/generator/step-metadata_test.go @@ -65,7 +65,7 @@ func writeFileMock(filename string, data []byte, perm os.FileMode) error { func TestProcessMetaFiles(t *testing.T) { - processMetaFiles([]string{"test.yaml"}, configOpenFileMock, writeFileMock) + ProcessMetaFiles([]string{"test.yaml"}, configOpenFileMock, writeFileMock, "") t.Run("step code", func(t *testing.T) { goldenFilePath := filepath.Join("testdata", t.Name()+"_generated.golden") @@ -170,7 +170,7 @@ func TestGetStepInfo(t *testing.T) { }, } - myStepInfo := getStepInfo(&stepData, true) + myStepInfo := getStepInfo(&stepData, true, "") assert.Equal(t, "testStep", myStepInfo.StepName, "StepName incorrect") assert.Equal(t, "TestStepCommand", myStepInfo.CobraCmdFuncName, "CobraCmdFuncName incorrect") diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden index 3f3d2d760..4817ccdac 100644 --- a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden +++ b/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden @@ -3,6 +3,7 @@ package cmd import ( "os" + "github.com/SAP/jenkins-library/pkg/config" "github.com/SAP/jenkins-library/pkg/log" "github.com/spf13/cobra" From 92054ad8aa4eff851453860eae545284e00e541b Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Fri, 8 Nov 2019 11:28:42 +0100 Subject: [PATCH 122/141] Fix karma metadata --- resources/metadata/karma.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/metadata/karma.yaml b/resources/metadata/karma.yaml index f10a58bfa..466570c19 100644 --- a/resources/metadata/karma.yaml +++ b/resources/metadata/karma.yaml @@ -52,8 +52,14 @@ spec: mandatory: true #outputs: containers: - - name: maven - image: maven:3.5-jdk-8 + - name: karma + image: node:8-stretch + env: + - name: no_proxy + value: localhost,selenium,$no_proxy + - name: NO_PROXY + value: localhost,selenium,$NO_PROXY + workingDir: /home/node volumeMounts: - mountPath: /dev/shm name: dev-shm From dca4a079b7d24829f9fbebd04c2e6c0b227405f6 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Fri, 8 Nov 2019 15:35:11 +0100 Subject: [PATCH 123/141] Refactor structure of generator (#960) --- pkg/generator/helper/helper.go | 308 ++++++++++++++++++ .../helper_test.go} | 3 +- .../step_code_generated.golden | 0 .../test_code_generated.golden | 0 pkg/generator/step-metadata.go | 290 +---------------- 5 files changed, 312 insertions(+), 289 deletions(-) create mode 100644 pkg/generator/helper/helper.go rename pkg/generator/{step-metadata_test.go => helper/helper_test.go} (99%) rename pkg/generator/{ => helper}/testdata/TestProcessMetaFiles/step_code_generated.golden (100%) rename pkg/generator/{ => helper}/testdata/TestProcessMetaFiles/test_code_generated.golden (100%) diff --git a/pkg/generator/helper/helper.go b/pkg/generator/helper/helper.go new file mode 100644 index 000000000..438b10407 --- /dev/null +++ b/pkg/generator/helper/helper.go @@ -0,0 +1,308 @@ +package helper + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/SAP/jenkins-library/pkg/config" +) + +type stepInfo struct { + CobraCmdFuncName string + CreateCmdVar string + ExportPrefix string + FlagsFunc string + Long string + Metadata []config.StepParameters + OSImport bool + Short string + StepFunc string + StepName string +} + +//StepGoTemplate ... +const stepGoTemplate = `package cmd + +import ( + {{if .OSImport}}"os"{{end}} + + {{if .ExportPrefix}}{{ .ExportPrefix }} "github.com/SAP/jenkins-library/cmd"{{end}} + "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/spf13/cobra" +) + +type {{ .StepName }}Options struct { + {{- range $key, $value := .Metadata }} + {{ $value.Name | golangName }} {{ $value.Type }} ` + "`json:\"{{$value.Name}},omitempty\"`" + `{{end}} +} + +var my{{ .StepName | title}}Options {{.StepName}}Options +var {{ .StepName }}StepConfigJSON string + +// {{.CobraCmdFuncName}} {{.Short}} +func {{.CobraCmdFuncName}}() *cobra.Command { + metadata := {{ .StepName }}Metadata() + var {{.CreateCmdVar}} = &cobra.Command{ + Use: "{{.StepName}}", + Short: "{{.Short}}", + Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, + PreRunE: func(cmd *cobra.Command, args []string) error { + log.SetStepName("{{ .StepName }}") + log.SetVerbose({{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}GeneralConfig.Verbose) + return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}OpenPiperFile) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return {{.StepName}}(my{{ .StepName | title }}Options) + }, + } + + {{.FlagsFunc}}({{.CreateCmdVar}}) + return {{.CreateCmdVar}} +} + +func {{.FlagsFunc}}(cmd *cobra.Command) { + {{- range $key, $value := .Metadata }} + cmd.Flags().{{ $value.Type | flagType }}(&my{{ $.StepName | title }}Options.{{ $value.Name | golangName }}, "{{ $value.Name }}", {{ $value.Default }}, "{{ $value.Description }}"){{ end }} + {{- printf "\n" }} + {{- range $key, $value := .Metadata }}{{ if $value.Mandatory }} + cmd.MarkFlagRequired("{{ $value.Name }}"){{ end }}{{ end }} +} + +// retrieve step metadata +func {{ .StepName }}Metadata() config.StepData { + var theMetaData = config.StepData{ + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + {{- range $key, $value := .Metadata }} + { + Name: "{{ $value.Name }}", + Scope: []string{{ "{" }}{{ range $notused, $scope := $value.Scope }}"{{ $scope }}",{{ end }}{{ "}" }}, + Type: "{{ $value.Type }}", + Mandatory: {{ $value.Mandatory }}, + },{{ end }} + }, + }, + }, + } + return theMetaData +} +` + +//StepTestGoTemplate ... +const stepTestGoTemplate = `package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test{{.CobraCmdFuncName}}(t *testing.T) { + + testCmd := {{.CobraCmdFuncName}}() + + // only high level testing performed - details are tested in step generation procudure + assert.Equal(t, "{{.StepName}}", testCmd.Use, "command name incorrect") + +} +` + +// ProcessMetaFiles generates step coding based on step configuration provided in yaml files +func ProcessMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCloser, error), writeFile func(filename string, data []byte, perm os.FileMode) error, exportPrefix string) error { + for key := range metadataFiles { + + var stepData config.StepData + + configFilePath := metadataFiles[key] + + metadataFile, err := openFile(configFilePath) + checkError(err) + defer metadataFile.Close() + + fmt.Printf("Reading file %v\n", configFilePath) + + err = stepData.ReadPipelineStepData(metadataFile) + checkError(err) + + fmt.Printf("Step name: %v\n", stepData.Metadata.Name) + + osImport := false + osImport, err = setDefaultParameters(&stepData) + checkError(err) + + myStepInfo := getStepInfo(&stepData, osImport, exportPrefix) + + step := stepTemplate(myStepInfo) + err = writeFile(fmt.Sprintf("cmd/%v_generated.go", stepData.Metadata.Name), step, 0644) + checkError(err) + + test := stepTestTemplate(myStepInfo) + err = writeFile(fmt.Sprintf("cmd/%v_generated_test.go", stepData.Metadata.Name), test, 0644) + checkError(err) + } + return nil +} + +func openMetaFile(name string) (io.ReadCloser, error) { + return os.Open(name) +} + +func fileWriter(filename string, data []byte, perm os.FileMode) error { + return ioutil.WriteFile(filename, data, perm) +} + +func setDefaultParameters(stepData *config.StepData) (bool, error) { + //ToDo: custom function for default handling, support all relevant parameter types + osImportRequired := false + for k, param := range stepData.Spec.Inputs.Parameters { + + if param.Default == nil { + switch param.Type { + case "string": + param.Default = fmt.Sprintf("os.Getenv(\"PIPER_%v\")", param.Name) + osImportRequired = true + case "bool": + // ToDo: Check if default should be read from env + param.Default = "false" + case "[]string": + // ToDo: Check if default should be read from env + param.Default = "[]string{}" + default: + return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + } + } else { + switch param.Type { + case "string": + param.Default = fmt.Sprintf("\"%v\"", param.Default) + case "bool": + boolVal := "false" + if param.Default.(bool) == true { + boolVal = "true" + } + param.Default = boolVal + case "[]string": + param.Default = fmt.Sprintf("[]string{\"%v\"}", strings.Join(param.Default.([]string), "\", \"")) + default: + return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) + } + } + + stepData.Spec.Inputs.Parameters[k] = param + } + return osImportRequired, nil +} + +func getStepInfo(stepData *config.StepData, osImport bool, exportPrefix string) stepInfo { + return stepInfo{ + StepName: stepData.Metadata.Name, + CobraCmdFuncName: fmt.Sprintf("%vCommand", strings.Title(stepData.Metadata.Name)), + CreateCmdVar: fmt.Sprintf("create%vCmd", strings.Title(stepData.Metadata.Name)), + Short: stepData.Metadata.Description, + Long: stepData.Metadata.LongDescription, + Metadata: stepData.Spec.Inputs.Parameters, + FlagsFunc: fmt.Sprintf("add%vFlags", strings.Title(stepData.Metadata.Name)), + OSImport: osImport, + ExportPrefix: exportPrefix, + } +} + +func checkError(err error) { + if err != nil { + fmt.Printf("Error occured: %v\n", err) + os.Exit(1) + } +} + +// MetadataFiles provides a list of all step metadata files +func MetadataFiles(sourceDirectory string) ([]string, error) { + + var metadataFiles []string + + err := filepath.Walk(sourceDirectory, func(path string, info os.FileInfo, err error) error { + if filepath.Ext(path) == ".yaml" { + metadataFiles = append(metadataFiles, path) + } + return nil + }) + if err != nil { + return metadataFiles, nil + } + return metadataFiles, nil +} + +func stepTemplate(myStepInfo stepInfo) []byte { + + funcMap := template.FuncMap{ + "flagType": flagType, + "golangName": golangName, + "title": strings.Title, + "longName": longName, + } + + tmpl, err := template.New("step").Funcs(funcMap).Parse(stepGoTemplate) + checkError(err) + + var generatedCode bytes.Buffer + err = tmpl.Execute(&generatedCode, myStepInfo) + checkError(err) + + return generatedCode.Bytes() +} + +func stepTestTemplate(myStepInfo stepInfo) []byte { + + funcMap := template.FuncMap{ + "flagType": flagType, + "golangName": golangName, + "title": strings.Title, + } + + tmpl, err := template.New("stepTest").Funcs(funcMap).Parse(stepTestGoTemplate) + checkError(err) + + var generatedCode bytes.Buffer + err = tmpl.Execute(&generatedCode, myStepInfo) + checkError(err) + + return generatedCode.Bytes() +} + +func longName(long string) string { + l := strings.ReplaceAll(long, "`", "` + \"`\" + `") + l = strings.TrimSpace(l) + return l +} + +func golangName(name string) string { + properName := strings.Replace(name, "Api", "API", -1) + properName = strings.Replace(properName, "api", "API", -1) + properName = strings.Replace(properName, "Url", "URL", -1) + properName = strings.Replace(properName, "Id", "ID", -1) + properName = strings.Replace(properName, "Json", "JSON", -1) + properName = strings.Replace(properName, "json", "JSON", -1) + return strings.Title(properName) +} + +func flagType(paramType string) string { + var theFlagType string + switch paramType { + case "bool": + theFlagType = "BoolVar" + case "string": + theFlagType = "StringVar" + case "[]string": + theFlagType = "StringSliceVar" + default: + fmt.Printf("Meta data type not set or not known: '%v'\n", paramType) + os.Exit(1) + } + return theFlagType +} diff --git a/pkg/generator/step-metadata_test.go b/pkg/generator/helper/helper_test.go similarity index 99% rename from pkg/generator/step-metadata_test.go rename to pkg/generator/helper/helper_test.go index b8a5c1a6c..7280c6f45 100644 --- a/pkg/generator/step-metadata_test.go +++ b/pkg/generator/helper/helper_test.go @@ -1,7 +1,6 @@ -package main +package helper import ( - //"bytes" "fmt" "io" "io/ioutil" diff --git a/pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden b/pkg/generator/helper/testdata/TestProcessMetaFiles/step_code_generated.golden similarity index 100% rename from pkg/generator/testdata/TestProcessMetaFiles/step_code_generated.golden rename to pkg/generator/helper/testdata/TestProcessMetaFiles/step_code_generated.golden diff --git a/pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden b/pkg/generator/helper/testdata/TestProcessMetaFiles/test_code_generated.golden similarity index 100% rename from pkg/generator/testdata/TestProcessMetaFiles/test_code_generated.golden rename to pkg/generator/helper/testdata/TestProcessMetaFiles/test_code_generated.golden diff --git a/pkg/generator/step-metadata.go b/pkg/generator/step-metadata.go index 161b85372..24fec23d0 100644 --- a/pkg/generator/step-metadata.go +++ b/pkg/generator/step-metadata.go @@ -1,129 +1,23 @@ package main import ( - "bytes" "fmt" "io" "io/ioutil" "os" "os/exec" - "path/filepath" - "strings" - "text/template" - "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/generator/helper" ) -type stepInfo struct { - CobraCmdFuncName string - CreateCmdVar string - ExportPrefix string - FlagsFunc string - Long string - Metadata []config.StepParameters - OSImport bool - Short string - StepFunc string - StepName string -} - -//StepGoTemplate ... -const stepGoTemplate = `package cmd - -import ( - {{if .OSImport}}"os"{{end}} - - {{if .ExportPrefix}}{{ .ExportPrefix }} "github.com/SAP/jenkins-library/cmd"{{end}} - "github.com/SAP/jenkins-library/pkg/config" - "github.com/SAP/jenkins-library/pkg/log" - "github.com/spf13/cobra" -) - -type {{ .StepName }}Options struct { - {{- range $key, $value := .Metadata }} - {{ $value.Name | golangName }} {{ $value.Type }} ` + "`json:\"{{$value.Name}},omitempty\"`" + `{{end}} -} - -var my{{ .StepName | title}}Options {{.StepName}}Options -var {{ .StepName }}StepConfigJSON string - -// {{.CobraCmdFuncName}} {{.Short}} -func {{.CobraCmdFuncName}}() *cobra.Command { - metadata := {{ .StepName }}Metadata() - var {{.CreateCmdVar}} = &cobra.Command{ - Use: "{{.StepName}}", - Short: "{{.Short}}", - Long: {{ $tick := "` + "`" + `" }}{{ $tick }}{{.Long | longName }}{{ $tick }}, - PreRunE: func(cmd *cobra.Command, args []string) error { - log.SetStepName("{{ .StepName }}") - log.SetVerbose({{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}GeneralConfig.Verbose) - return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}OpenPiperFile) - }, - RunE: func(cmd *cobra.Command, args []string) error { - return {{.StepName}}(my{{ .StepName | title }}Options) - }, - } - - {{.FlagsFunc}}({{.CreateCmdVar}}) - return {{.CreateCmdVar}} -} - -func {{.FlagsFunc}}(cmd *cobra.Command) { - {{- range $key, $value := .Metadata }} - cmd.Flags().{{ $value.Type | flagType }}(&my{{ $.StepName | title }}Options.{{ $value.Name | golangName }}, "{{ $value.Name }}", {{ $value.Default }}, "{{ $value.Description }}"){{ end }} - {{- printf "\n" }} - {{- range $key, $value := .Metadata }}{{ if $value.Mandatory }} - cmd.MarkFlagRequired("{{ $value.Name }}"){{ end }}{{ end }} -} - -// retrieve step metadata -func {{ .StepName }}Metadata() config.StepData { - var theMetaData = config.StepData{ - Spec: config.StepSpec{ - Inputs: config.StepInputs{ - Parameters: []config.StepParameters{ - {{- range $key, $value := .Metadata }} - { - Name: "{{ $value.Name }}", - Scope: []string{{ "{" }}{{ range $notused, $scope := $value.Scope }}"{{ $scope }}",{{ end }}{{ "}" }}, - Type: "{{ $value.Type }}", - Mandatory: {{ $value.Mandatory }}, - },{{ end }} - }, - }, - }, - } - return theMetaData -} -` - -//StepTestGoTemplate ... -const stepTestGoTemplate = `package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test{{.CobraCmdFuncName}}(t *testing.T) { - - testCmd := {{.CobraCmdFuncName}}() - - // only high level testing performed - details are tested in step generation procudure - assert.Equal(t, "{{.StepName}}", testCmd.Use, "command name incorrect") - -} -` - func main() { metadataPath := "./resources/metadata" - metadataFiles, err := MetadataFiles(metadataPath) + metadataFiles, err := helper.MetadataFiles(metadataPath) checkError(err) - err = ProcessMetaFiles(metadataFiles, openMetaFile, fileWriter, "") + err = helper.ProcessMetaFiles(metadataFiles, openMetaFile, fileWriter, "") checkError(err) cmd := exec.Command("go", "fmt", "./cmd") @@ -131,43 +25,6 @@ func main() { checkError(err) } - -// ProcessMetaFiles generates step coding based on step configuration provided in yaml files -func ProcessMetaFiles(metadataFiles []string, openFile func(s string) (io.ReadCloser, error), writeFile func(filename string, data []byte, perm os.FileMode) error, exportPrefix string) error { - for key := range metadataFiles { - - var stepData config.StepData - - configFilePath := metadataFiles[key] - - metadataFile, err := openFile(configFilePath) - checkError(err) - defer metadataFile.Close() - - fmt.Printf("Reading file %v\n", configFilePath) - - err = stepData.ReadPipelineStepData(metadataFile) - checkError(err) - - fmt.Printf("Step name: %v\n", stepData.Metadata.Name) - - osImport := false - osImport, err = setDefaultParameters(&stepData) - checkError(err) - - myStepInfo := getStepInfo(&stepData, osImport, exportPrefix) - - step := stepTemplate(myStepInfo) - err = writeFile(fmt.Sprintf("cmd/%v_generated.go", stepData.Metadata.Name), step, 0644) - checkError(err) - - test := stepTestTemplate(myStepInfo) - err = writeFile(fmt.Sprintf("cmd/%v_generated_test.go", stepData.Metadata.Name), test, 0644) - checkError(err) - } - return nil -} - func openMetaFile(name string) (io.ReadCloser, error) { return os.Open(name) } @@ -176,150 +33,9 @@ func fileWriter(filename string, data []byte, perm os.FileMode) error { return ioutil.WriteFile(filename, data, perm) } -func setDefaultParameters(stepData *config.StepData) (bool, error) { - //ToDo: custom function for default handling, support all relevant parameter types - osImportRequired := false - for k, param := range stepData.Spec.Inputs.Parameters { - - if param.Default == nil { - switch param.Type { - case "string": - param.Default = fmt.Sprintf("os.Getenv(\"PIPER_%v\")", param.Name) - osImportRequired = true - case "bool": - // ToDo: Check if default should be read from env - param.Default = "false" - case "[]string": - // ToDo: Check if default should be read from env - param.Default = "[]string{}" - default: - return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) - } - } else { - switch param.Type { - case "string": - param.Default = fmt.Sprintf("\"%v\"", param.Default) - case "bool": - boolVal := "false" - if param.Default.(bool) == true { - boolVal = "true" - } - param.Default = boolVal - case "[]string": - param.Default = fmt.Sprintf("[]string{\"%v\"}", strings.Join(param.Default.([]string), "\", \"")) - default: - return false, fmt.Errorf("Meta data type not set or not known: '%v'", param.Type) - } - } - - stepData.Spec.Inputs.Parameters[k] = param - } - return osImportRequired, nil -} - -func getStepInfo(stepData *config.StepData, osImport bool, exportPrefix string) stepInfo { - return stepInfo{ - StepName: stepData.Metadata.Name, - CobraCmdFuncName: fmt.Sprintf("%vCommand", strings.Title(stepData.Metadata.Name)), - CreateCmdVar: fmt.Sprintf("create%vCmd", strings.Title(stepData.Metadata.Name)), - Short: stepData.Metadata.Description, - Long: stepData.Metadata.LongDescription, - Metadata: stepData.Spec.Inputs.Parameters, - FlagsFunc: fmt.Sprintf("add%vFlags", strings.Title(stepData.Metadata.Name)), - OSImport: osImport, - ExportPrefix: exportPrefix, - } -} - func checkError(err error) { if err != nil { fmt.Printf("Error occured: %v\n", err) os.Exit(1) } } - -// MetadataFiles provides a list of all step metadata files -func MetadataFiles(sourceDirectory string) ([]string, error) { - - var metadataFiles []string - - err := filepath.Walk(sourceDirectory, func(path string, info os.FileInfo, err error) error { - if filepath.Ext(path) == ".yaml" { - metadataFiles = append(metadataFiles, path) - } - return nil - }) - if err != nil { - return metadataFiles, nil - } - return metadataFiles, nil -} - -func stepTemplate(myStepInfo stepInfo) []byte { - - funcMap := template.FuncMap{ - "flagType": flagType, - "golangName": golangName, - "title": strings.Title, - "longName": longName, - } - - tmpl, err := template.New("step").Funcs(funcMap).Parse(stepGoTemplate) - checkError(err) - - var generatedCode bytes.Buffer - err = tmpl.Execute(&generatedCode, myStepInfo) - checkError(err) - - return generatedCode.Bytes() -} - -func stepTestTemplate(myStepInfo stepInfo) []byte { - - funcMap := template.FuncMap{ - "flagType": flagType, - "golangName": golangName, - "title": strings.Title, - } - - tmpl, err := template.New("stepTest").Funcs(funcMap).Parse(stepTestGoTemplate) - checkError(err) - - var generatedCode bytes.Buffer - err = tmpl.Execute(&generatedCode, myStepInfo) - checkError(err) - - return generatedCode.Bytes() -} - -func longName(long string) string { - l := strings.ReplaceAll(long, "`", "` + \"`\" + `") - l = strings.TrimSpace(l) - return l -} - -func golangName(name string) string { - properName := strings.Replace(name, "Api", "API", -1) - properName = strings.Replace(properName, "api", "API", -1) - properName = strings.Replace(properName, "Url", "URL", -1) - properName = strings.Replace(properName, "Id", "ID", -1) - properName = strings.Replace(properName, "Json", "JSON", -1) - properName = strings.Replace(properName, "json", "JSON", -1) - return strings.Title(properName) -} - -func flagType(paramType string) string { - var theFlagType string - switch paramType { - case "bool": - theFlagType = "BoolVar" - case "string": - theFlagType = "StringVar" - case "[]string": - theFlagType = "StringSliceVar" - default: - fmt.Printf("Meta data type not set or not known: '%v'\n", paramType) - os.Exit(1) - } - return theFlagType -} From d7efd33a5b14f799295ace132e9d165969abd07a Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 11 Nov 2019 09:42:47 +0100 Subject: [PATCH 124/141] Fix bug in step generator (#964) When using step generator outside of the current package the `PrepareConfig` call was not done using the prefix. --- pkg/generator/helper/helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/generator/helper/helper.go b/pkg/generator/helper/helper.go index 438b10407..39bbcec9c 100644 --- a/pkg/generator/helper/helper.go +++ b/pkg/generator/helper/helper.go @@ -56,7 +56,7 @@ func {{.CobraCmdFuncName}}() *cobra.Command { PreRunE: func(cmd *cobra.Command, args []string) error { log.SetStepName("{{ .StepName }}") log.SetVerbose({{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}GeneralConfig.Verbose) - return PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}OpenPiperFile) + return {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}PrepareConfig(cmd, &metadata, "{{ .StepName }}", &my{{ .StepName | title}}Options, {{if .ExportPrefix}}{{ .ExportPrefix }}.{{end}}OpenPiperFile) }, RunE: func(cmd *cobra.Command, args []string) error { return {{.StepName}}(my{{ .StepName | title }}Options) From c9883bf5b082350c0bce8f68d55a87d9913f8011 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 11 Nov 2019 09:52:44 +0100 Subject: [PATCH 125/141] Pr/read project config only if it exists (#959) * Read the project config only if it exists This avoid trying reading the file and have the control flow based on errors. Beside that it helps troubleshooting when we have some logging (debug level only). * formatting only * Adjust log level --- cmd/piper.go | 16 +++++++++++++--- cmd/piper_test.go | 8 ++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/cmd/piper.go b/cmd/piper.go index 9f2995c11..d74474f56 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/piperutils" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -78,9 +80,18 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin stepConfig = config.GetStepConfigWithJSON(flagValues, GeneralConfig.StepConfigJSON, filters) } else { // use config & defaults - + var customConfig io.ReadCloser + var err error //accept that config file and defaults cannot be loaded since both are not mandatory here - customConfig, _ := openFile(GeneralConfig.CustomConfig) + if piperutils.FileExists(GeneralConfig.CustomConfig) { + if customConfig, err = openFile(GeneralConfig.CustomConfig); err != nil { + errors.Wrapf(err, "Cannot read '%s'", GeneralConfig.CustomConfig) + } + } else { + log.Entry().Infof("Project config file '%s' does not exist. No project configuration available.", GeneralConfig.CustomConfig) + customConfig = nil + } + var defaultConfig []io.ReadCloser for _, f := range GeneralConfig.DefaultConfig { //ToDo: support also https as source @@ -88,7 +99,6 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin defaultConfig = append(defaultConfig, fc) } - var err error stepConfig, err = myConfig.GetStepConfig(flagValues, GeneralConfig.ParametersJSON, customConfig, defaultConfig, filters, metadata.Spec.Inputs.Parameters, GeneralConfig.StageName, stepName) if err != nil { return errors.Wrap(err, "retrieving step configuration failed") diff --git a/cmd/piper_test.go b/cmd/piper_test.go index 35e2ee5c5..8d94cae7e 100644 --- a/cmd/piper_test.go +++ b/cmd/piper_test.go @@ -13,8 +13,8 @@ import ( ) type execMockRunner struct { - dir []string - calls []execCall + dir []string + calls []execCall shouldFailWith error } @@ -24,8 +24,8 @@ type execCall struct { } type shellMockRunner struct { - dir string - calls []string + dir string + calls []string shouldFailWith error } From 4e9fef433a64607e58226386cacf67ea23adbff1 Mon Sep 17 00:00:00 2001 From: Sven Merk <33895725+nevskrem@users.noreply.github.com> Date: Mon, 11 Nov 2019 10:44:03 +0100 Subject: [PATCH 126/141] Update createDocu.groovy --- documentation/bin/createDocu.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/bin/createDocu.groovy b/documentation/bin/createDocu.groovy index d7c81d325..a7cc9395b 100644 --- a/documentation/bin/createDocu.groovy +++ b/documentation/bin/createDocu.groovy @@ -839,7 +839,8 @@ def handleStep(stepName, gse) { File theStepDocu = new File(stepsDocuDir, "${stepName}.md") File theStepDeps = new File('documentation/jenkins_workspace/plugin_mapping.json') - if (!theStepDocu.exists() && stepName.indexOf('Stage') != -1) { + def stageNameFields = stepName.split('Stage') + if (!theStepDocu.exists() && stepName.indexOf('Stage') != -1 && stageNameFields.size() > 1) { //try to get a corresponding stage documentation def stageName = stepName.split('Stage')[1].toLowerCase() theStepDocu = new File(stagesDocuDir,"${stageName}.md" ) From c9dcfd5578edb9f96f4ab99f4660d221cc1c21bd Mon Sep 17 00:00:00 2001 From: Sven Merk Date: Mon, 11 Nov 2019 10:06:40 +0100 Subject: [PATCH 127/141] Fix comment format --- vars/cfManifestSubstituteVariables.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vars/cfManifestSubstituteVariables.groovy b/vars/cfManifestSubstituteVariables.groovy index 5da646e09..96adde9f8 100644 --- a/vars/cfManifestSubstituteVariables.groovy +++ b/vars/cfManifestSubstituteVariables.groovy @@ -48,7 +48,7 @@ import static com.sap.piper.Prerequisites.checkScript @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS -/* +/** * Step to substitute variables in a given YAML file with those specified in one or more variables files given by the * `manifestVariablesFiles` parameter. This follows the behavior of `cf push --vars-file`, and can be * used as a pre-deployment step if commands other than `cf push` are used for deployment (e.g. `cf blue-green-deploy`). From 2bb400910a781a2680a87f61ce1c38d7a1e398d6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Mon, 11 Nov 2019 15:31:02 +0100 Subject: [PATCH 128/141] assign library field in logger with respect of containing repository (#968) * set loggers library entry to repository url * remove comment --- Dockerfile | 6 +++++- pkg/log/log.go | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index bd7f989eb..f7c22d6a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,11 @@ RUN go test ./... -cover # execute build # RUN go build -o piper RUN export GIT_COMMIT=$(git rev-parse HEAD) && \ - go build -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT}" -o piper + export GIT_REPOSITORY=$(git config --get remote.origin.url) && \ + go build \ + -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT}" \ + -ldflags "-X github.com/SAP/jenkins-library/pkg/log.LibraryRepository=${GIT_REPOSITORY}" \ + -o piper # FROM gcr.io/distroless/base:latest # COPY --from=build-env /build/piper /piper diff --git a/pkg/log/log.go b/pkg/log/log.go index 1cd7eeeb3..04e2345d0 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -4,12 +4,14 @@ import ( "github.com/sirupsen/logrus" ) +// LibraryRepository that is passed into with -ldflags +var LibraryRepository string var logger *logrus.Entry // Entry returns the logger entry or creates one if none is present. func Entry() *logrus.Entry { if logger == nil { - logger = logrus.WithField("library", "sap/jenkins-library") + logger = logrus.WithField("library", LibraryRepository) } return logger } From de465f2e203bf864a8cb14bb25787a94c42e6209 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Mon, 11 Nov 2019 16:38:25 +0100 Subject: [PATCH 129/141] fix: correct ldflags (#969) --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f7c22d6a1..3b8d0b54f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,9 @@ RUN go test ./... -cover RUN export GIT_COMMIT=$(git rev-parse HEAD) && \ export GIT_REPOSITORY=$(git config --get remote.origin.url) && \ go build \ - -ldflags "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT}" \ - -ldflags "-X github.com/SAP/jenkins-library/pkg/log.LibraryRepository=${GIT_REPOSITORY}" \ + -ldflags \ + "-X github.com/SAP/jenkins-library/cmd.GitCommit=${GIT_COMMIT} \ + -X github.com/SAP/jenkins-library/pkg/log.LibraryRepository=${GIT_REPOSITORY}" \ -o piper # FROM gcr.io/distroless/base:latest From da0935c0e5fdba5afeb4f220bd349fc5d5ca02b3 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 11 Nov 2019 17:40:23 +0100 Subject: [PATCH 130/141] Patch Urls (#929) * Patch Urls * fix test --- documentation/docs/steps/dockerExecute.md | 2 +- documentation/docs/steps/seleniumExecuteTests.md | 2 +- test/groovy/HadolintExecuteTest.groovy | 4 ++-- test/groovy/MailSendNotificationTest.groovy | 16 ++++++++-------- test/groovy/WhitesourceExecuteScanTest.groovy | 8 ++++---- .../com/sap/piper/DescriptorUtilsTest.groovy | 4 ++-- .../integration/WhitesourceRepositoryTest.groovy | 10 +++++----- .../templates/PiperPipelineStageInitTest.groovy | 2 +- test/resources/DescriptorUtils/go/glide.yaml | 4 ++-- test/resources/utilsTest/setup.py | 2 +- vars/piperPipelineStageInit.groovy | 2 +- 11 files changed, 28 insertions(+), 28 deletions(-) diff --git a/documentation/docs/steps/dockerExecute.md b/documentation/docs/steps/dockerExecute.md index d0e3f46e0..cb709508b 100644 --- a/documentation/docs/steps/dockerExecute.md +++ b/documentation/docs/steps/dockerExecute.md @@ -55,7 +55,7 @@ dockerExecute( sidecarImage: 'selenium/standalone-chrome', sidecarName: 'selenium', ) { - git url: 'https://github.wdf.sap.corp/XXXXX/WebDriverIOTest.git' + git url: 'https://github.com/XXXXX/WebDriverIOTest.git' sh '''npm install node index.js ''' diff --git a/documentation/docs/steps/seleniumExecuteTests.md b/documentation/docs/steps/seleniumExecuteTests.md index b6070d962..80c6e9eec 100644 --- a/documentation/docs/steps/seleniumExecuteTests.md +++ b/documentation/docs/steps/seleniumExecuteTests.md @@ -10,7 +10,7 @@ none ```groovy seleniumExecuteTests (script: this) { - git url: 'https://github.wdf.sap.corp/xxxxx/WebDriverIOTest.git' + git url: 'https://github.com/xxxxx/WebDriverIOTest.git' sh '''npm install node index.js''' } diff --git a/test/groovy/HadolintExecuteTest.groovy b/test/groovy/HadolintExecuteTest.groovy index 852ee28a6..e133316d6 100644 --- a/test/groovy/HadolintExecuteTest.groovy +++ b/test/groovy/HadolintExecuteTest.groovy @@ -46,12 +46,12 @@ class HadolintExecuteTest extends BasePiperTest { @Test void testHadolintExecute() { - stepRule.step.hadolintExecute(script: nullScript, juStabUtils: utils, dockerImage: 'hadolint/hadolint:latest-debian', configurationUrl: 'https://github.wdf.sap.corp/raw/SGS/Hadolint-Dockerfile/master/.hadolint.yaml') + stepRule.step.hadolintExecute(script: nullScript, juStabUtils: utils, dockerImage: 'hadolint/hadolint:latest-debian', configurationUrl: 'https://github.com/raw/SGS/Hadolint-Dockerfile/master/.hadolint.yaml') assertThat(dockerExecuteRule.dockerParams.dockerImage, is('hadolint/hadolint:latest-debian')) assertThat(loggingRule.log, containsString("Unstash content: buildDescriptor")) assertThat(shellRule.shell, hasItems( - "curl --fail --location --output .hadolint.yaml https://github.wdf.sap.corp/raw/SGS/Hadolint-Dockerfile/master/.hadolint.yaml", + "curl --fail --location --output .hadolint.yaml https://github.com/raw/SGS/Hadolint-Dockerfile/master/.hadolint.yaml", "hadolint ./Dockerfile --config .hadolint.yaml --format checkstyle > hadolint.xml" ) ) diff --git a/test/groovy/MailSendNotificationTest.groovy b/test/groovy/MailSendNotificationTest.groovy index 408f2a3b4..4b3573bbe 100644 --- a/test/groovy/MailSendNotificationTest.groovy +++ b/test/groovy/MailSendNotificationTest.groovy @@ -66,7 +66,7 @@ user3@domain.com noreply+github@domain.com''' def result = stepRule.step.getCulprits( [ gitSSHCredentialsId: '', - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git', + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git', gitCommitId: 'f0973368a35a2b973612acb86f932c61f2635f6e' ], 'master', @@ -84,7 +84,7 @@ user3@domain.com noreply+github@domain.com''' stepRule.step.getCulprits( [ gitSSHCredentialsId: '', - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git', + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git', gitCommitId: '' ], 'master', @@ -101,7 +101,7 @@ user3@domain.com noreply+github@domain.com''' stepRule.step.getCulprits( [ gitSSHCredentialsId: '', - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git', + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git', gitCommitId: null ], 'master', @@ -118,7 +118,7 @@ user3@domain.com noreply+github@domain.com''' stepRule.step.getCulprits( [ gitSSHCredentialsId: '', - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git', + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git', gitCommitId: '' ], null, @@ -135,7 +135,7 @@ user3@domain.com noreply+github@domain.com''' displayName: 'testDisplayName', result: 'FAILURE', rawBuild: [ - getLog: { cnt -> return ['Setting http proxy: proxy.wdf.domain.com:8080', + getLog: { cnt -> return ['Setting http proxy: proxy.domain.com:8080', ' > git fetch --no-tags --progress https://github.com/SAP/jenkins-library.git +refs/heads/*:refs/remotes/origin/*', 'Checking out Revision myUniqueCommitId (master)', ' > git config core.sparsecheckout # timeout=10', @@ -165,7 +165,7 @@ user3@domain.com noreply+github@domain.com''' stepRule.step.mailSendNotification( script: nullScript, notifyCulprits: false, - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git' + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git' ) // asserts assertThat(emailParameters.to, is('piper@domain.com')) @@ -199,7 +199,7 @@ user3@domain.com noreply+github@domain.com''' script: nullScript, gitCommitId: 'abcd1234', //notifyCulprits: true, - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git' + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git' ) // asserts assertThat(credentials, hasItem('myCredentialsId')) @@ -228,7 +228,7 @@ user3@domain.com noreply+github@domain.com''' stepRule.step.mailSendNotification( script: nullScript, gitCommitId: 'abcd1234', - gitUrl: 'git@github.wdf.domain.com:IndustryCloudFoundation/pipeline-test-node.git' + gitUrl: 'git@github.domain.com:IndustryCloudFoundation/pipeline-test-node.git' ) // asserts assertThat(credentials, hasItem('')) diff --git a/test/groovy/WhitesourceExecuteScanTest.groovy b/test/groovy/WhitesourceExecuteScanTest.groovy index b3f737d0a..5faff2f72 100644 --- a/test/groovy/WhitesourceExecuteScanTest.groovy +++ b/test/groovy/WhitesourceExecuteScanTest.groovy @@ -495,7 +495,7 @@ class WhitesourceExecuteScanTest extends BasePiperTest { @Test void testGo() { - nullScript.commonPipelineEnvironment.gitHttpsUrl = 'https://github.wdf.sap.corp/test/golang' + nullScript.commonPipelineEnvironment.gitHttpsUrl = 'https://github.com/test/golang' helper.registerAllowedMethod("readFile", [Map.class], { map -> @@ -548,12 +548,12 @@ class WhitesourceExecuteScanTest extends BasePiperTest { assertThat(writeFileRule.files['./myProject/wss-unified-agent.config.7d1c90ed46c66061fc8ea45dd96e209bf767f038'], containsString('productName=testProductName')) assertThat(writeFileRule.files['./myProject/wss-unified-agent.config.7d1c90ed46c66061fc8ea45dd96e209bf767f038'], containsString('userKey=token-0815')) assertThat(writeFileRule.files['./myProject/wss-unified-agent.config.7d1c90ed46c66061fc8ea45dd96e209bf767f038'], containsString('productVersion=1')) - assertThat(writeFileRule.files['./myProject/wss-unified-agent.config.7d1c90ed46c66061fc8ea45dd96e209bf767f038'], containsString('projectName=github.wdf.sap.corp/test/golang.myProject')) + assertThat(writeFileRule.files['./myProject/wss-unified-agent.config.7d1c90ed46c66061fc8ea45dd96e209bf767f038'], containsString('projectName=github.com/test/golang.myProject')) } @Test void testGoDefaults() { - nullScript.commonPipelineEnvironment.gitHttpsUrl = 'https://github.wdf.sap.corp/test/golang' + nullScript.commonPipelineEnvironment.gitHttpsUrl = 'https://github.com/test/golang' helper.registerAllowedMethod("readFile", [Map.class], { map -> @@ -605,7 +605,7 @@ class WhitesourceExecuteScanTest extends BasePiperTest { assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('productName=testProductName')) assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('userKey=token-0815')) assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('productVersion=1')) - assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('projectName=github.wdf.sap.corp/test/golang')) + assertThat(writeFileRule.files['./wss-unified-agent.config.d3aa80454919391024374ba46b4df082d15ab9a3'], containsString('projectName=github.com/test/golang')) } diff --git a/test/groovy/com/sap/piper/DescriptorUtilsTest.groovy b/test/groovy/com/sap/piper/DescriptorUtilsTest.groovy index 8287116c7..c610b7cea 100644 --- a/test/groovy/com/sap/piper/DescriptorUtilsTest.groovy +++ b/test/groovy/com/sap/piper/DescriptorUtilsTest.groovy @@ -220,10 +220,10 @@ class DescriptorUtilsTest extends BasePiperTest { return null }) - def gav = descriptorUtils.getGoGAV('./myProject/Gopkg.toml', new URI('https://github.wdf.sap.corp/test/golang')) + def gav = descriptorUtils.getGoGAV('./myProject/Gopkg.toml', new URI('https://github.com/test/golang')) assertEquals('', gav.group) - assertEquals('github.wdf.sap.corp/test/golang.myProject', gav.artifact) + assertEquals('github.com/test/golang.myProject', gav.artifact) assertEquals('1.2.3', gav.version) } } diff --git a/test/groovy/com/sap/piper/integration/WhitesourceRepositoryTest.groovy b/test/groovy/com/sap/piper/integration/WhitesourceRepositoryTest.groovy index 5f3043a0f..2a4c7e01c 100644 --- a/test/groovy/com/sap/piper/integration/WhitesourceRepositoryTest.groovy +++ b/test/groovy/com/sap/piper/integration/WhitesourceRepositoryTest.groovy @@ -33,7 +33,7 @@ class WhitesourceRepositoryTest extends BasePiperTest { @Before void init() throws Exception { - nullScript.env['HTTP_PROXY'] = "http://proxy.wdf.sap.corp:8080" + nullScript.env['HTTP_PROXY'] = "http://proxy.org:8080" repository = new WhitesourceRepository(nullScript, [whitesource: [serviceUrl: "http://some.host.whitesource.com/api/"]]) LibraryLoadingTestExecutionListener.prepareObjectInterceptors(repository) @@ -253,7 +253,7 @@ class WhitesourceRepositoryTest extends BasePiperTest { contentType: 'APPLICATION_JSON', requestBody: requestBody, quiet : false, - proxy : "http://proxy.wdf.sap.corp:8080" + proxy : "http://proxy.org:8080" ] )) } @@ -278,7 +278,7 @@ class WhitesourceRepositoryTest extends BasePiperTest { contentType: 'APPLICATION_JSON', requestBody: requestBody, quiet : false, - proxy : "http://proxy.wdf.sap.corp:8080", + proxy : "http://proxy.org:8080", userKey : "4711" ] )) @@ -286,7 +286,7 @@ class WhitesourceRepositoryTest extends BasePiperTest { @Test void testHttpWhitesourceInternalCallUserKey() { - def config = [whitesource: [serviceUrl: "http://mo-323123123.sap.corp/some", userKey: "4711"], verbose: false] + def config = [whitesource: [serviceUrl: "http://test.org/some", userKey: "4711"], verbose: false] def requestBody = "{ \"someJson\" : { \"someObject\" : \"abcdef\" } }" def requestParams @@ -328,7 +328,7 @@ class WhitesourceRepositoryTest extends BasePiperTest { @Test void testFetchReportForProduct() { - repository.config.putAll([whitesource: [serviceUrl: "http://mo-323123123.sap.corp/some", productToken: "4712", userKey: "4711"], verbose: true]) + repository.config.putAll([whitesource: [serviceUrl: "http://test.org/some", productToken: "4712", userKey: "4711"], verbose: true]) def requestBody = "{ \"requestType\": \"getProductRiskReport\", \"productToken\": \"${repository.config.whitesource.productToken}\" }" def requestParams diff --git a/test/groovy/templates/PiperPipelineStageInitTest.groovy b/test/groovy/templates/PiperPipelineStageInitTest.groovy index 92580540d..d232c4b76 100644 --- a/test/groovy/templates/PiperPipelineStageInitTest.groovy +++ b/test/groovy/templates/PiperPipelineStageInitTest.groovy @@ -93,7 +93,7 @@ class PiperPipelineStageInitTest extends BasePiperTest { @Test void testInitBuildToolDoesNotMatchProject() { - thrown.expectMessage('[piperPipelineStageInit] buildTool configuration \'npm\' does not fit to your project, please set buildTool as genereal setting in your .pipeline/config.yml correctly, see also https://github.wdf.sap.corp/pages/ContinuousDelivery/piper-doc/configuration/') + thrown.expectMessage('[piperPipelineStageInit] buildTool configuration \'npm\' does not fit to your project, please set buildTool as genereal setting in your .pipeline/config.yml correctly, see also https://sap.github.io/jenkins-library/configuration/') jsr.step.piperPipelineStageInit( script: nullScript, juStabUtils: utils, diff --git a/test/resources/DescriptorUtils/go/glide.yaml b/test/resources/DescriptorUtils/go/glide.yaml index 300df9240..bc81bee78 100644 --- a/test/resources/DescriptorUtils/go/glide.yaml +++ b/test/resources/DescriptorUtils/go/glide.yaml @@ -1,7 +1,7 @@ -package: github.wdf.sap.corp/TestOrg/GolangTest +package: github.com/TestOrg/GolangTest import: - package: github.com/julienschmidt/httprouter version: ^1.1.0 - package: github.com/tebeka/go2xunit version: ^1.4.4 -- package: github.wdf.sap.corp/dtxmake-acceptance/golang-sample +- package: github.com/dtxmake-acceptance/golang-sample diff --git a/test/resources/utilsTest/setup.py b/test/resources/utilsTest/setup.py index bf0a51216..bc57ca169 100644 --- a/test/resources/utilsTest/setup.py +++ b/test/resources/utilsTest/setup.py @@ -6,7 +6,7 @@ setup( description='This is a python package to handle some ci-connect payload parts', - url='https://github.wdf.sap.corp/sap-production/py_connect', + url='https://github.com/sap-production/py_connect', # Author details author='Some Author', diff --git a/vars/piperPipelineStageInit.groovy b/vars/piperPipelineStageInit.groovy index 049311b2e..43d844835 100644 --- a/vars/piperPipelineStageInit.groovy +++ b/vars/piperPipelineStageInit.groovy @@ -121,7 +121,7 @@ private void checkBuildTool(config) { break } if (buildDescriptorPattern && !findFiles(glob: buildDescriptorPattern)) { - error "[${STEP_NAME}] buildTool configuration '${config.buildTool}' does not fit to your project, please set buildTool as genereal setting in your .pipeline/config.yml correctly, see also https://github.wdf.sap.corp/pages/ContinuousDelivery/piper-doc/configuration/" + error "[${STEP_NAME}] buildTool configuration '${config.buildTool}' does not fit to your project, please set buildTool as genereal setting in your .pipeline/config.yml correctly, see also https://sap.github.io/jenkins-library/configuration/" } } From 8f723caa31b1e442d8bf195b607a3d0641347c6a Mon Sep 17 00:00:00 2001 From: Shanuson <54803480+Shanuson@users.noreply.github.com> Date: Tue, 12 Nov 2019 10:29:08 +0100 Subject: [PATCH 131/141] Fix bug and added documentation for cloudFoundryCreateService step (#967) * fix bug with wrong plugin parameter used * provided default value for stash-content * added documentation for step --- .../docs/steps/cloudFoundryCreateService.md | 37 +++++++++++++++++ resources/default_pipeline_environment.yml | 2 + .../CloudFoundryCreateServiceTest.groovy | 40 +++++++++---------- vars/cloudFoundryCreateService.groovy | 7 ++-- 4 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 documentation/docs/steps/cloudFoundryCreateService.md diff --git a/documentation/docs/steps/cloudFoundryCreateService.md b/documentation/docs/steps/cloudFoundryCreateService.md new file mode 100644 index 000000000..878d7a12f --- /dev/null +++ b/documentation/docs/steps/cloudFoundryCreateService.md @@ -0,0 +1,37 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} + +## Example + +The following Example will create the services specified in a file `manifest-create-service.yml` in cloud foundry org `cfOrg` of Cloud Foundry installation accessed via `https://test.server.com` in space `cfSpace` by using the username & password stored in `cfCredentialsId`. + +```groovy +cloudFoundryCreateService( + script: this, + cloudFoundry: [apiEndpoint: 'https://test.server.com', + credentialsId: 'cfCredentialsId', + serviceManifest: 'manifest-create-service.yml', + org: 'cfOrg', + space: 'cfSpace']) +``` + +The following example additionally to above also makes use of a variable substitution file `mainfest-variable-substitution.yml`. + +```groovy +cloudFoundryCreateService( + script: this, + cloudFoundry: [apiEndpoint: 'https://test.server.com', + credentialsId: 'cfCredentialsId', + serviceManifest: 'manifest-create-service.yml', + manifestVariablesFiles: ['mainfest-variable-substitution.yml'], + org: 'cfOrg', + space: 'cfSpace']) + +``` diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 8db70a17b..e93262210 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -187,6 +187,8 @@ steps: serviceManifest: 'service-manifest.yml' dockerImage: 'ppiper/cf-cli' dockerWorkspace: '/home/piper' + stashContent: + - 'deployDescriptor' detectExecuteScan: detect: projectVersion: '1' diff --git a/test/groovy/CloudFoundryCreateServiceTest.groovy b/test/groovy/CloudFoundryCreateServiceTest.groovy index 005e807d4..0dcf4defb 100644 --- a/test/groovy/CloudFoundryCreateServiceTest.groovy +++ b/test/groovy/CloudFoundryCreateServiceTest.groovy @@ -139,7 +139,7 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) - assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push --service-manifest 'test.yml'"))) assertThat(shellRule.shell, hasItem(containsString("cf logout"))) } @@ -156,16 +156,16 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: 'testSpace', - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml', cfManifestVariablesFiles: [varsFileName], cfManifestVariables: varsList - ]) + ]) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) - assertThat(shellRule.shell, hasItem(containsString("cf create-service-push --no-push -f 'test.yml' --var appName='testApplicationFromVarsList' --vars-file 'vars.yml'"))) + assertThat(shellRule.shell, hasItem(containsString("cf create-service-push --no-push --service-manifest 'test.yml' --var appName='testApplicationFromVarsList' --vars-file 'vars.yml'"))) assertThat(shellRule.shell, hasItem(containsString("cf logout"))) } @@ -180,9 +180,9 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: 'testSpace', - cfCredentialsId: 'escape_cfCredentialsId', + cfCredentialsId: 'escape_cfCredentialsId', cfServiceManifest: 'test.yml' - ]) + ]) assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'aUserWithA'"'"'' -p 'passHasA'"'"'' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"""))) } @@ -196,9 +196,9 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: "testSpaceWith'", - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml' - ]) + ]) assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpaceWith'"'"''"""))) } @@ -211,9 +211,9 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: "testOrgWith'", cfSpace: "testSpace", - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml' - ]) + ]) assertThat(shellRule.shell, hasItem(containsString("""cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrgWith'"'"'' -s 'testSpace'"""))) } @@ -228,12 +228,12 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: 'testSpace', - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml', cfManifestVariables: varsList - ]) + ]) - assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push -f 'test.yml' --var appName='testApplicationFromVarsListWith'"'"''"""))) + assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push --service-manifest 'test.yml' --var appName='testApplicationFromVarsListWith'"'"''"""))) } @Test @@ -248,12 +248,12 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: 'testSpace', - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml', cfManifestVariablesFiles: [varsFileName] - ]) + ]) - assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push -f 'test.yml' --vars-file 'varsWith'"'"'.yml'"""))) + assertThat(shellRule.shell, hasItem(containsString("""cf create-service-push --no-push --service-manifest 'test.yml' --vars-file 'varsWith'"'"'.yml'"""))) } @Test @@ -271,14 +271,14 @@ class CloudFoundryCreateServiceTest extends BasePiperTest { deployTool: 'cf_native', cfOrg: 'testOrg', cfSpace: 'testSpace', - cfCredentialsId: 'test_cfCredentialsId', + cfCredentialsId: 'test_cfCredentialsId', cfServiceManifest: 'test.yml' - ]) + ]) assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerImage', 'ppiper/cf-cli')) - assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) + assertThat(dockerExecuteRule.dockerParams, hasEntry('dockerWorkspace', '/home/piper')) assertThat(shellRule.shell, hasItem(containsString("cf login -u 'test_cf' -p '********' -a https://api.cf.eu10.hana.ondemand.com -o 'testOrg' -s 'testSpace'"))) - assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push -f 'test.yml'"))) + assertThat(shellRule.shell, hasItem(containsString(" cf create-service-push --no-push --service-manifest 'test.yml'"))) assertThat(shellRule.shell, hasItem(containsString("cf logout"))) } } diff --git a/vars/cloudFoundryCreateService.groovy b/vars/cloudFoundryCreateService.groovy index cff2e4299..7cdec66a6 100644 --- a/vars/cloudFoundryCreateService.groovy +++ b/vars/cloudFoundryCreateService.groovy @@ -79,9 +79,10 @@ import static com.sap.piper.Prerequisites.checkScript @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS /** - * Uses the Create-Service-Push plugin to create services in a Cloud Foundry space. + * Step that uses the CF Create-Service-Push plugin to create services in a Cloud Foundry space. The information about the services is provided in a yaml file as infrastructure as code. + * It is possible to use variable substitution inside of the yaml file like in a CF-push manifest yaml. * - * For details how to specify the services see the [github page of the plugin](https://github.com/dawu415/CF-CLI-Create-Service-Push-Plugin). + * For more details how to specify the services in the yaml see the [github page of the plugin](https://github.com/dawu415/CF-CLI-Create-Service-Push-Plugin). * * The `--no-push` options is always used with the plugin. To deploy the application make use of the cloudFoundryDeploy step! */ @@ -130,7 +131,7 @@ private def executeCreateServicePush(script, Map config) { set -e export HOME=${config.dockerWorkspace} cf login -u ${BashUtils.quoteAndEscape(CF_USERNAME)} -p ${BashUtils.quoteAndEscape(CF_PASSWORD)} -a ${config.cloudFoundry.apiEndpoint} -o ${BashUtils.quoteAndEscape(config.cloudFoundry.org)} -s ${BashUtils.quoteAndEscape(config.cloudFoundry.space)}; - cf create-service-push --no-push -f ${BashUtils.quoteAndEscape(config.cloudFoundry.serviceManifest)}${varPart}${varFilePart} + cf create-service-push --no-push --service-manifest ${BashUtils.quoteAndEscape(config.cloudFoundry.serviceManifest)}${varPart}${varFilePart} """ sh "cf logout" if (returnCode!=0) { From 19a433db8c9a0e95d8851c77c630abeba20bf314 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 12 Nov 2019 15:43:51 +0100 Subject: [PATCH 132/141] Remove commented coding --- src/com/sap/piper/DefaultValueCache.groovy | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/com/sap/piper/DefaultValueCache.groovy b/src/com/sap/piper/DefaultValueCache.groovy index 99a486f65..bab5ba47d 100644 --- a/src/com/sap/piper/DefaultValueCache.groovy +++ b/src/com/sap/piper/DefaultValueCache.groovy @@ -6,8 +6,6 @@ import com.sap.piper.MapUtils class DefaultValueCache implements Serializable { private static DefaultValueCache instance - //static CommonPipelineEnvironment commonPipelineEnvironment = new CommonPipelineEnvironment() - private Map defaultValues private DefaultValueCache(Map defaultValues){ From 74365f741957c14db26f3a6e4e5c97f065294b03 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 12 Nov 2019 15:49:02 +0100 Subject: [PATCH 133/141] Remove unused import --- test/groovy/ChecksPublishResultsTest.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/test/groovy/ChecksPublishResultsTest.groovy b/test/groovy/ChecksPublishResultsTest.groovy index 697040f05..0d0d0515c 100644 --- a/test/groovy/ChecksPublishResultsTest.groovy +++ b/test/groovy/ChecksPublishResultsTest.groovy @@ -4,7 +4,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import com.sap.piper.DefaultValueCache import com.sap.piper.CommonPipelineEnvironment import org.junit.Ignore From 7ed89ea22351bcc1f671b00fd7372589742a4910 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 12 Nov 2019 15:57:16 +0100 Subject: [PATCH 134/141] Don't use deprecated methods with CPE null --- test/groovy/com/sap/piper/ConfigurationHelperTest.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index a137d912b..ec5cc4d9f 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -109,9 +109,9 @@ class ConfigurationHelperTest { Map config = ConfigurationHelper.newInstance(mockScript, [property1: '27']) .loadStepDefaults() - .mixinGeneralConfig(null, null, [general2: 'oldGeneral']) - .mixinStageConfig(null, 'testStage', null, [stage2: 'oldStage']) - .mixinStepConfig(null, null, [step2: 'oldStep']) + .mixinGeneralConfig(null, [general2: 'oldGeneral']) + .mixinStageConfig('testStage', null, [stage2: 'oldStage']) + .mixinStepConfig(null, [step2: 'oldStep']) .mixin([property1: '41', property2: '28', property3: '29'], filter) .use() // asserts From d6fb0cb5dd97d427bf823f4ca6c68a06c8066a3b Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 12 Nov 2019 16:02:28 +0100 Subject: [PATCH 135/141] remove handle deprecation, was for debugging/troubleshooting only --- src/com/sap/piper/ConfigurationHelper.groovy | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index 3d1232c0d..d221919ff 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -29,22 +29,6 @@ class ConfigurationHelper implements Serializable { if(!this.name) throw new IllegalArgumentException('Step has no public name property!') } - /* - * By default this methods does nothing. With this method we are able to ensure that we do not call the - * deprecated methods. Might be usefull during local development. - */ - private static handleDeprecation(script, String methodName) { - if(script != null) { - def msg = "ConfigurationHelper.${methodName} was called with a script reference." + - 'This method is deprecated. Use the same method without the script reference' - if(Boolean.getBoolean('com.sap.piper.failOnScriptReferenceInConfigurationHelper')) - throw new RuntimeException(msg) - if(Boolean.getBoolean('com.sap.piper.emitWarningOnScriptReferenceInConfigurationHelper') && - script instanceof Script) script.echo("[WARNING] ${msg}") - } - } - - ConfigurationHelper collectValidationFailures() { validationResults = validationResults ?: [:] return this @@ -56,7 +40,6 @@ class ConfigurationHelper implements Serializable { @Deprecated /** Use mixinGeneralConfig without commonPipelineEnvironment*/ ConfigurationHelper mixinGeneralConfig(commonPipelineEnvironment, Set filter = null, Map compatibleParameters = [:]){ - handleDeprecation(commonPipelineEnvironment, 'mixinGeneralConfig') Map generalConfiguration = ConfigurationLoader.generalConfiguration() return mixin(generalConfiguration, filter, compatibleParameters) } @@ -66,7 +49,6 @@ class ConfigurationHelper implements Serializable { } @Deprecated ConfigurationHelper mixinStageConfig(commonPipelineEnvironment, stageName, Set filter = null, Map compatibleParameters = [:]){ - handleDeprecation(commonPipelineEnvironment, 'mixinStageConfig') Map stageConfiguration = ConfigurationLoader.stageConfiguration(stageName) return mixin(stageConfiguration, filter, compatibleParameters) } @@ -76,7 +58,6 @@ class ConfigurationHelper implements Serializable { } @Deprecated ConfigurationHelper mixinStepConfig(commonPipelineEnvironment, Set filter = null, Map compatibleParameters = [:]){ - handleDeprecation(commonPipelineEnvironment, 'mixinStepConfig') Map stepConfiguration = ConfigurationLoader.stepConfiguration(name) return mixin(stepConfiguration, filter, compatibleParameters) } From f47abf032ba41d9b88e503a5cbaae48a0adf6d21 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 12 Nov 2019 16:07:37 +0100 Subject: [PATCH 136/141] remove duplicate import --- vars/commonPipelineEnvironment.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/vars/commonPipelineEnvironment.groovy b/vars/commonPipelineEnvironment.groovy index 8863348f3..4d1b8e468 100644 --- a/vars/commonPipelineEnvironment.groovy +++ b/vars/commonPipelineEnvironment.groovy @@ -1,7 +1,6 @@ import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.ConfigurationLoader import com.sap.piper.ConfigurationMerger -import com.sap.piper.CommonPipelineEnvironment import com.sap.piper.analytics.InfluxData class commonPipelineEnvironment implements Serializable { From a822c2026e38e21af3a43b3bd105824370e868b9 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Tue, 12 Nov 2019 16:34:05 +0100 Subject: [PATCH 137/141] Add publishing of master binary to latest release (#972) * Add publishing of master binary to latest release * Update travis.yml --- .pipeline/config.yml | 4 ++++ .travis.yml | 17 +++++++++++++++-- Dockerfile | 1 - 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .pipeline/config.yml diff --git a/.pipeline/config.yml b/.pipeline/config.yml new file mode 100644 index 000000000..3d4a9b3bf --- /dev/null +++ b/.pipeline/config.yml @@ -0,0 +1,4 @@ +steps: + githubPublishRelease: + owner: SAP + repository: jenkins-library diff --git a/.travis.yml b/.travis.yml index f902be465..b3ce80b72 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,13 +22,26 @@ cache: jobs: include: - stage: Tests - name: Unit Tests + name: Golang Build + if: type = pull_request + script: + - docker build -t piper:${TRAVIS_BRANCH} . + - name: Golang Build & Publish + if: type != pull_request && repo = "SAP/jenkins-library" && branch = "master" + script: + - docker build -t piper:${TRAVIS_BRANCH} . + - docker create --name piper_${TRAVIS_BRANCH} piper:${TRAVIS_BRANCH} + - docker cp piper_${TRAVIS_BRANCH}:/build/piper . + - docker rm piper_${TRAVIS_BRANCH} + - cp ./piper ./piper_master + - chmod +x ./piper + - ./piper githubPublishRelease --token ${GITHUB_TOKEN} --version latest --updateAsset --assetPath ./piper_master + - name: Groovy Unit Tests before_script: - curl -L --output cc-test-reporter https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 - chmod +x ./cc-test-reporter - ./cc-test-reporter before-build script: - - docker build -t piper:latest . - mvn package --batch-mode after_script: - JACOCO_SOURCE_PATH="src vars test" ./cc-test-reporter format-coverage target/site/jacoco/jacoco.xml --input-type jacoco diff --git a/Dockerfile b/Dockerfile index 3b8d0b54f..9e0b0d93b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ RUN go test ./... -cover ## ONLY tests so far, building to be added later # execute build -# RUN go build -o piper RUN export GIT_COMMIT=$(git rev-parse HEAD) && \ export GIT_REPOSITORY=$(git config --get remote.origin.url) && \ go build \ From 7466ae43af5738ddb0a6cdf36066157f6055a6c6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Tue, 12 Nov 2019 17:17:44 +0100 Subject: [PATCH 138/141] fix: sonar PR voting (#971) * unstash git files into container * use CHANGE_BRANCH * adapt env variables --- test/groovy/SonarExecuteScanTest.groovy | 2 +- vars/sonarExecuteScan.groovy | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/groovy/SonarExecuteScanTest.groovy b/test/groovy/SonarExecuteScanTest.groovy index b40ca7e8b..de960b36e 100644 --- a/test/groovy/SonarExecuteScanTest.groovy +++ b/test/groovy/SonarExecuteScanTest.groovy @@ -130,7 +130,7 @@ class SonarExecuteScanTest extends BasePiperTest { binding.setVariable('env', [ 'CHANGE_ID': '42', 'CHANGE_TARGET': 'master', - 'BRANCH_NAME': 'feature/anything' + 'CHANGE_BRANCH': 'feature/anything' ]) nullScript.commonPipelineEnvironment.setGithubOrg('testOrg') //nullScript.commonPipelineEnvironment.setGithubRepo('testRepo') diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index b82f96926..b5580a27d 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -173,7 +173,7 @@ void call(Map parameters = [:]) { // see https://sonarcloud.io/documentation/analysis/pull-request/ config.options.add("sonar.pullrequest.key=${env.CHANGE_ID}") config.options.add("sonar.pullrequest.base=${env.CHANGE_TARGET}") - config.options.add("sonar.pullrequest.branch=${env.BRANCH_NAME}") + config.options.add("sonar.pullrequest.branch=${env.CHANGE_BRANCH}") config.options.add("sonar.pullrequest.provider=${config.pullRequestProvider}") switch(config.pullRequestProvider){ case 'GitHub': @@ -190,6 +190,7 @@ void call(Map parameters = [:]) { script: script, dockerImage: configuration.dockerImage ){ + unstash 'git' worker(configuration) } } From cac595b4bbb85f5151bd650b5d85aaac80a90535 Mon Sep 17 00:00:00 2001 From: Daniel Mieg <56156797+DanielMieg@users.noreply.github.com> Date: Tue, 12 Nov 2019 17:40:59 +0100 Subject: [PATCH 139/141] Use credentialsId in step abapEnvironmentPullGitRepo (#974) * Add option for credentialsId * Remove mandatory username and password * Add null checks --- .../docs/steps/abapEnvironmentPullGitRepo.md | 16 ++++++--------- .../AbapEnvironmentPullGitRepoTest.groovy | 16 ++++++++------- vars/abapEnvironmentPullGitRepo.groovy | 20 +++++++++---------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/documentation/docs/steps/abapEnvironmentPullGitRepo.md b/documentation/docs/steps/abapEnvironmentPullGitRepo.md index aa6f7503f..b199d9ca6 100644 --- a/documentation/docs/steps/abapEnvironmentPullGitRepo.md +++ b/documentation/docs/steps/abapEnvironmentPullGitRepo.md @@ -6,7 +6,6 @@ * A SAP Cloud Platform ABAP Environment system is available. * On this system, a [Communication User](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/0377adea0401467f939827242c1f4014.html), a [Communication System](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/1bfe32ae08074b7186e375ab425fb114.html) and a [Communication Arrangement](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/a0771f6765f54e1c8193ad8582a32edb.html) is setup for the Communication Scenario "SAP Cloud Platform ABAP Environment - Software Component Test Integration (SAP_COM_0510)". -* It is recommended to use the Jenkins credentials configuration for user and password handling and wrap the call to "abapEnvironmentPullGitRepo" with the Jenkins Step "withCredentials". ## ${docGenParameters} @@ -17,13 +16,10 @@ ## Example ```groovy -withCredentials([usernamePassword(credentialsId: 'myCredentialsId', usernameVariable: 'USER', passwordVariable: 'PASSWORD')]) { - abapEnvironmentPullGitRepo( - host : ' 1234-abcd-5678-efgh-ijk.abap.eu10.hana.ondemand.com', - repositoryName : '/DMO/GIT_REPOSITORY', - username : "\$USER", - password : "\$PASSWORD", - script : this - ) -} +abapEnvironmentPullGitRepo ( + host : '1234-abcd-5678-efgh-ijk.abap.eu10.hana.ondemand.com', + repositoryName : '/DMO/GIT_REPOSITORY', + credentialsId : "myCredentialsId", + script : this +) ``` diff --git a/test/groovy/AbapEnvironmentPullGitRepoTest.groovy b/test/groovy/AbapEnvironmentPullGitRepoTest.groovy index 244de7418..7e98dd344 100644 --- a/test/groovy/AbapEnvironmentPullGitRepoTest.groovy +++ b/test/groovy/AbapEnvironmentPullGitRepoTest.groovy @@ -27,6 +27,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { private JenkinsStepRule stepRule = new JenkinsStepRule(this) private JenkinsLoggingRule loggingRule = new JenkinsLoggingRule(this) private JenkinsShellCallRule shellRule = new JenkinsShellCallRule(this) + private JenkinsCredentialsRule credentialsRule = new JenkinsCredentialsRule(this).withCredentials('test_credentialsId', 'user', 'password') @Rule public RuleChain ruleChain = Rules.getCommonRules(this) @@ -34,6 +35,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { .around(thrown) .around(stepRule) .around(loggingRule) + .around(credentialsRule) .around(shellRule) @Before @@ -57,7 +59,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { loggingRule.expect("[abapEnvironmentPullGitRepo] Entity URI: https://example.com/URI") loggingRule.expect("[abapEnvironmentPullGitRepo] Pull Status: SUCCESS") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', credentialsId: 'test_credentialsId') assertThat(shellRule.shell[0], containsString(/#!\/bin\/bash curl -I -X GET https:\/\/example.com\/sap\/opu\/odata\/sap\/MANAGE_GIT_REPOSITORY\/Pull -H 'Authorization: Basic dXNlcjpwYXNzd29yZA==' -H 'Accept: application\/json' -H 'x-csrf-token: fetch' -D headerFileAuth-1.txt/)) assertThat(shellRule.shell[1], containsString(/#!\/bin\/bash curl -X POST "https:\/\/example.com\/sap\/opu\/odata\/sap\/MANAGE_GIT_REPOSITORY\/Pull" -H 'Authorization: Basic dXNlcjpwYXNzd29yZA==' -H 'Accept: application\/json' -H 'Content-Type: application\/json' -H 'x-csrf-token: TOKEN' --cookie headerFileAuth-1.txt -D headerFilePost-1.txt -d '{ "sc_name": "Z_DEMO_DM" }'/)) @@ -83,7 +85,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { thrown.expect(Exception) thrown.expectMessage("[abapEnvironmentPullGitRepo] Pull Failed") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', credentialsId: 'test_credentialsId') } @@ -104,7 +106,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { thrown.expect(Exception) thrown.expectMessage("[abapEnvironmentPullGitRepo] Pull Failed") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', credentialsId: 'test_credentialsId') } @@ -122,7 +124,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { thrown.expect(Exception) thrown.expectMessage("[abapEnvironmentPullGitRepo] Error: text") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', credentialsId: 'test_credentialsId') } @@ -139,7 +141,7 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { thrown.expect(Exception) thrown.expectMessage("[abapEnvironmentPullGitRepo] Error: 401 Unauthorized") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', repositoryName: 'Z_DEMO_DM', credentialsId: 'test_credentialsId') } @@ -147,14 +149,14 @@ public class AbapEnvironmentPullGitRepoTest extends BasePiperTest { public void checkRepositoryProvided() { thrown.expect(IllegalArgumentException) thrown.expectMessage("Repository / Software Component not provided") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, host: 'example.com', credentialsId: 'test_credentialsId') } @Test public void checkHostProvided() { thrown.expect(IllegalArgumentException) thrown.expectMessage("Host not provided") - stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, repositoryName: 'REPO', username: 'user', password: 'password') + stepRule.step.abapEnvironmentPullGitRepo(script: nullScript, repositoryName: 'REPO', credentialsId: 'test_credentialsId') } @Test diff --git a/vars/abapEnvironmentPullGitRepo.groovy b/vars/abapEnvironmentPullGitRepo.groovy index fa5d7f3d9..af1705ba5 100644 --- a/vars/abapEnvironmentPullGitRepo.groovy +++ b/vars/abapEnvironmentPullGitRepo.groovy @@ -22,13 +22,9 @@ import java.util.UUID ] @Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([ /** - * Specifies the communication user of the communication scenario SAP_COM_0510 + * Jenkins CredentialsId containing the communication user and password of the communciation scenario SAP_COM_0510 */ - 'username', - /** - * Specifies the password of the communication user - */ - 'password' + 'credentialsId' ]) @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS /** @@ -58,12 +54,15 @@ void call(Map parameters = [:]) { .collectValidationFailures() .withMandatoryProperty('host', 'Host not provided') .withMandatoryProperty('repositoryName', 'Repository / Software Component not provided') - .withMandatoryProperty('username') - .withMandatoryProperty('password') + .withMandatoryProperty('credentialsId') .use() - String usernameColonPassword = configuration.username + ":" + configuration.password - String authToken = usernameColonPassword.bytes.encodeBase64().toString() + String authToken + withCredentials([usernamePassword(credentialsId: configuration.credentialsId, usernameVariable: 'USER', passwordVariable: 'PASSWORD')]) { + String userColonPassword = "${USER}:${PASSWORD}" + authToken = userColonPassword.bytes.encodeBase64().toString() + } + String urlString = 'https://' + configuration.host + '/sap/opu/odata/sap/MANAGE_GIT_REPOSITORY/Pull' echo "[${STEP_NAME}] General Parameters: URL = \"${urlString}\", repositoryName = \"${configuration.repositoryName}\"" HeaderFiles headerFiles = new HeaderFiles() @@ -133,7 +132,6 @@ private String triggerPull(Map configuration, String url, String authToken, Head private String pollPullStatus(String url, String authToken, HeaderFiles headerFiles) { - String headerFile = "headerPoll.txt" String status = "R"; while(status == "R") { From a89361449e178d0fad8cfa66fd701ece341491cc Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 13 Nov 2019 09:24:36 +0100 Subject: [PATCH 140/141] sonarExecuteScan: update to Sonar scanner 4.2.x (#820) * Update to Sonar scanner 4.x.x * switch to sonar-scanner 4.2.0 --- resources/default_pipeline_environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index e93262210..91160c6a7 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -511,7 +511,7 @@ steps: instance: 'SonarCloud' options: [] pullRequestProvider: 'GitHub' - sonarScannerDownloadUrl: 'https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-3.3.0.1492-linux.zip' + sonarScannerDownloadUrl: 'https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.2.0.1873-linux.zip' spinnakerTriggerPipeline: certFileCredentialsId: 'spinnaker-client-certificate' keyFileCredentialsId: 'spinnaker-client-key' From 14e7ef23b463677ded24716fc705e0f84546a9bb Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 13 Nov 2019 12:55:02 +0100 Subject: [PATCH 141/141] fix: sonarExecuteScan: safeguard unstash of git metadata (#976) * unstash .git folder only if not present, ignore missing stashes * fix: negate condition * Update sonarExecuteScan.groovy --- vars/sonarExecuteScan.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vars/sonarExecuteScan.groovy b/vars/sonarExecuteScan.groovy index b5580a27d..c879f8125 100644 --- a/vars/sonarExecuteScan.groovy +++ b/vars/sonarExecuteScan.groovy @@ -190,7 +190,9 @@ void call(Map parameters = [:]) { script: script, dockerImage: configuration.dockerImage ){ - unstash 'git' + if(!script.fileExists('.git')) { + utils.unstash('git') + } worker(configuration) } }