From 4f8f7be6aad9746c30f2bdb4791bf596b7f201c4 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 16 Jul 2018 13:29:00 +0200 Subject: [PATCH 01/24] Access to nested properties yaml configuration supports nested properties. With this change we can read those nested properties. --- src/com/sap/piper/ConfigurationHelper.groovy | 23 +++++++++++++++---- .../sap/piper/ConfigurationHelperTest.groovy | 21 +++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index ee78dc459..a9628d092 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -74,10 +74,7 @@ class ConfigurationHelper implements Serializable { } def getConfigProperty(key) { - if (config[key] != null && config[key].class == String) { - return config[key].trim() - } - return config[key] + return getConfigPropertyNested(config, key) } def getConfigProperty(key, defaultValue) { @@ -88,6 +85,24 @@ class ConfigurationHelper implements Serializable { return value } + private getConfigPropertyNested(Map config, key) { + + List parts = (key in String) ? (key as CharSequence).tokenize('/') : ([key] as List) + + if(config[parts.head()] != null) { + + if(config[parts.head()] in Map && parts.size() > 1) { + return getConfigPropertyNested(config[parts.head()], String.join('/', parts[1..parts.size()-1])) + } + + if (config[parts.head()].class == String) { + return (config[parts.head()] as String).trim() + } + } + + return config[parts.head()] + } + def isPropertyDefined(key){ def value = getConfigProperty(key) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 478acb4a1..7bf123b2d 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -3,7 +3,10 @@ package com.sap.piper import groovy.test.GroovyAssert import static org.hamcrest.Matchers.* +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat +import org.hamcrest.Matchers import org.junit.Assert import org.junit.Rule import org.junit.Test @@ -29,6 +32,24 @@ class ConfigurationHelperTest { Assert.assertFalse(configuration.isPropertyDefined('something')) } + @Test + void testGetPropertyNestedLeafNodeIsString() { + def configuration = new ConfigurationHelper([a:[b: 'c']]) + assertThat(configuration.getConfigProperty('a/b'), is('c')) + } + + @Test + void testGetPropertyNestedLeafNodeIsMap() { + def configuration = new ConfigurationHelper([a:[b: [c: 'd']]]) + assertThat(configuration.getConfigProperty('a/b'), is([c: 'd'])) + } + + @Test + void testGetPropertyNestedPathNotFound() { + def configuration = new ConfigurationHelper([a:[b: 'c']]) + assertThat(configuration.getConfigProperty('a/c'), is((nullValue()))) + } + @Test void testIsPropertyDefined() { def configuration = new ConfigurationHelper(getConfiguration()) From 9b4d55d0d9b8f1d853b2fbb200c4ea2a29f5ac50 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 17 Jul 2018 09:21:56 +0200 Subject: [PATCH 02/24] switch to nested configuration for change management related steps --- .../docs/steps/checkChangeInDevelopment.md | 100 ++++++++++++++---- .../docs/steps/transportRequestCreate.md | 87 +++++++++++---- .../docs/steps/transportRequestRelease.md | 74 +++++++++++-- .../docs/steps/transportRequestUploadFile.md | 87 ++++++++++++--- resources/default_pipeline_environment.yml | 30 ++---- .../CheckChangeInDevelopmentTest.groovy | 18 ++-- test/groovy/TransportRequestCreateTest.groovy | 10 +- .../groovy/TransportRequestReleaseTest.groovy | 4 +- .../TransportRequestUploadFileTest.groovy | 4 +- vars/checkChangeInDevelopment.groovy | 44 ++++---- vars/transportRequestCreate.groovy | 37 +++---- vars/transportRequestRelease.groovy | 14 +-- vars/transportRequestUploadFile.groovy | 36 +++---- 13 files changed, 380 insertions(+), 165 deletions(-) diff --git a/documentation/docs/steps/checkChangeInDevelopment.md b/documentation/docs/steps/checkChangeInDevelopment.md index ed94ba146..f47bd79ee 100644 --- a/documentation/docs/steps/checkChangeInDevelopment.md +++ b/documentation/docs/steps/checkChangeInDevelopment.md @@ -1,11 +1,11 @@ # checkChangeInDevelopment ## Description -Checks if a Change Document is in status 'in development'. The change document id is retrieved from the git commit history. The change document id +Checks if a Change Document in SAP Solution Manager is in status 'in development'. The change document id is retrieved from the git commit history. The change document id can also be provided via parameter `changeDocumentId`. Any value provided as parameter has a higher precedence than a value from the commit history. By default the git commit messages between `origin/master` and `HEAD` are scanned for a line like `ChangeDocument : `. The commit -range and the pattern can be configured. For details see 'parameters' table. +range and the pattern can be configured. For details see 'parameters' table. ## 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. @@ -15,27 +15,72 @@ range and the pattern can be configured. For details see 'parameters' table. | -------------------|-----------|--------------------------------------------------------|--------------------| | `script` | yes | | | | `changeDocumentId` | yes | | | -| `credentialsId` | yes | | | -| `endpoint` | yes | | | -| `gitFrom` | no | `origin/master` | | -| `gitTo` | no | `HEAD` | | -| `gitChangeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | -| `gitFormat` | no | `%b` | see `git log --help` | +| `changeManagement/changeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | +| `changeManagement/credentialsId` | yes | | | +| `changeManagement/endpoint` | yes | | | +| `changeManagement/git/from` | no | `origin/master` | | +| `changeManagement/git/to` | no | `HEAD` | | +| `changeManagement/git/format` | no | `%b` | see `git log --help` | +| `failIfStatusIsNotInDevelopment` | no | `true` | `true`, `false` | * `script` - The common script environment of the Jenkinsfile running. Typically the reference to the script calling the pipeline step is provided with the `this` parameter, as in `script: this`. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for retrieving, for example, configuration parameters. * `changeDocumentId` - The id of the change document to transport. If not provided, it is retrieved from the git commit history. -* `credentialsId` - The credentials to connect to the Solution Manager. -* `endpoint` - The address of the Solution Manager. -* `gitFrom` - The starting point for retrieving the change document id -* `gitTo` - The end point for retrieving the change document id -* `gitChangeDocumentLabel` - A pattern used for identifying lines holding the change document id. -* `gitFormat` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. +* `changeManagement/changeDocumentLabel` - A pattern used for identifying lines holding the change document id. +* `changeManagement/credentialsId` - The id of the credentials to connect to the Solution Manager. The credentials needs to be maintained on Jenkins. +* `changeManagement/endpoint` - The address of the Solution Manager. +* `changeManagement/git/from` - The starting point for retrieving the change document id +* `changeManagement/git/to` - The end point for retrieving the change document id +* `changeManagement/git/format` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. +* `failIfStatusIsNotInDevelopment` - when set to `false` the step will not fail in case the step is not in status 'in development'. ## Step configuration -The following parameters can also be specified as step parameters using the global configuration file: +The step is configured using a customer configuration file provided as +resource in an custom shared library. -* `credentialsId` -* `endpoint` +``` +@Library('piper-library-os@master') _ + +// the shared lib containing the additional configuration +// needs to be configured in Jenkins +@Library(foo@master') __ + +// inside the shared lib denoted by 'foo' the additional configuration file +// needs to be located under 'resources' ('resoures/myConfig.yml') +prepareDefaultValues script: this, + customDefaults: 'myConfig.yml' +``` + +Example content of ```'resources/myConfig.yml'``` in branch ```'master'``` of the repository denoted by +```'foo'```: + +``` +general: + changeManagement: + changeDocumentLabel: 'ChangeDocument\s?:' + cmClientOpts: '-Djavax.net.ssl.trustStore=' + credentialsId: 'CM' + endpoint: 'https://example.org/cm' + git: + from: 'HEAD~1' + to: 'HEAD' + format: '%b' +``` + +The properties configured in section `'general/changeManagement'` are shared between all change managment related steps. + +The properties can also be configured on a per-step basis: + +``` + [...] + steps: + checkChangeInDevelopment: + changeManagement: + endpoint: 'https://example.org/cm' + [...] + failIfStatusIsNotInDevelopment: true +``` + +The parameters can also be provided when the step is invoked. For examples see below. ## Return value `true` in case the change document is in status 'in development'. Otherwise an hudson.AbortException is thrown. In case `failIfStatusIsNotInDevelopment` @@ -45,9 +90,26 @@ is set to `false`, `false` is returned in case the change document is not in sta * `AbortException`: * If the change id is not provided via parameter and if the change document id cannot be retrieved from the commit history. * If the change is not in status `in development`. In this case no exception will be thrown when `failIfStatusIsNotInDevelopment` is set to `false`. - -## Example +* `IllegalArgumentException`: + * If a mandatory property is not provided. +## Examples ```groovy + // simple case. All mandatory parameters provided via + // configuration, changeDocumentId provided via commit + // history checkChangeInDevelopment script:this ``` +```groovy + // explict endpoint provided, we search for changeDocumentId + // starting at the previous commit (HEAD~1) rather than on + // 'origin/master' (the default). + checkChangeInDevelopment script:this + changeManagement: [ + endpoint: 'https:example.org/cm' + git: [ + from: 'HEAD~1' + ] + ] +``` + diff --git a/documentation/docs/steps/transportRequestCreate.md b/documentation/docs/steps/transportRequestCreate.md index 321ad7fc3..c3bd341ca 100644 --- a/documentation/docs/steps/transportRequestCreate.md +++ b/documentation/docs/steps/transportRequestCreate.md @@ -11,41 +11,88 @@ Creates a Transport Request for a Change Document on the Solution Manager. | -----------------|-----------|--------------------------------------------------------|--------------------| | `script` | yes | | | | `changeDocumentId` | yes | | | -| `credentialsId` | yes | | | -| `endpoint` | yes | | | -| `clientOpts` | no | | | -| `gitFrom` | no | `origin/master` | | -| `gitTo` | no | `HEAD` | | -| `gitChangeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | -| `gitFormat` | no | `%b` | see `git log --help` | +| `changeManagement/credentialsId` | yes | | | +| `changeManagement/endpoint` | yes | | | +| `changeManagement/clientOpts` | no | | | +| `changeManagement/git/from` | no | `origin/master` | | +| `changeManagement/git/to` | no | `HEAD` | | +| `changeManagement/changeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | +| `changeManagement/git/format` | no | `%b` | see `git log --help` | * `script` - The common script environment of the Jenkinsfile running. Typically the reference to the script calling the pipeline step is provided with the `this` parameter, as in `script: this`. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for retrieving, for example, configuration parameters. * `changeDocumentId` - The id of the change document to transport. -* `credentialsId` - The credentials to connect to the Solution Manager. -* `endpoint` - The address of the Solution Manager. -* `clientOpts`- Options forwarded to JVM used by the CM client, like `JAVA_OPTS` -* `gitFrom` - The starting point for retrieving the change document id -* `gitTo` - The end point for retrieving the change document id -* `gitChangeDocumentLabel` - A pattern used for identifying lines holding the change document id. -* `gitFormat` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. +* `changeManagement/credentialsId` - The credentials to connect to the Solution Manager. +* `changeManagement/endpoint` - The address of the Solution Manager. +* `changeManagement/clientOpts`- Options forwarded to JVM used by the CM client, like `JAVA_OPTS` +* `changeManagement/git/from` - The starting point for retrieving the change document id +* `changeManagement/git/to` - The end point for retrieving the change document id +* `changeManagement/changeDocumentLabel` - A pattern used for identifying lines holding the change document id. +* `changeManagement/git/format` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. ## Step configuration -The following parameters can also be specified as step parameters using the global configuration file: +The step is configured using a customer configuration file provided as +resource in an custom shared library. -* `credentialsId` -* `endpoint` -* `clientOpts` +``` +@Library('piper-library-os@master') _ + +// the shared lib containing the additional configuration +// needs to be configured in Jenkins +@Library(foo@master') __ + +// inside the shared lib denoted by 'foo' the additional configuration file +// needs to be located under 'resources' ('resoures/myConfig.yml') +prepareDefaultValues script: this, + customDefaults: 'myConfig.yml' +``` + +Example content of ```'resources/myConfig.yml'``` in branch ```'master'``` of the repository denoted by +```'foo'```: + +``` +general: + changeManagement: + changeDocumentLabel: 'ChangeDocument\s?:' + cmClientOpts: '-Djavax.net.ssl.trustStore=' + credentialsId: 'CM' + endpoint: 'https://example.org/cm' + git: + from: 'HEAD~1' + to: 'HEAD' + format: '%b' +``` + +The properties configured in section `'general/changeManagement'` are shared between +all change managment related steps. + +The properties can also be configured on a per-step basis: + +``` + [...] + steps: + transportRequestCreate: + changeManagement: + endpoint: 'https://example.org/cm' + [...] +``` + +The parameters can also be provided when the step is invoked. For examples see below. ## Return value The id of the Transport Request that has been created. ## Exceptions * `AbortException`: - * If the change id is not provided. * If the creation of the transport request fails. +* `IllegalStateException`: + * If the change id is not provided. ## Example ```groovy -def transportRequestId = transportRequestCreate script:this, changeDocumentId: '001' +def transportRequestId = transportRequestCreate script:this, + changeDocumentId: '001,' + changeManagement: [ + endpoint: 'https://example.org/cm' + ] ``` diff --git a/documentation/docs/steps/transportRequestRelease.md b/documentation/docs/steps/transportRequestRelease.md index 5e1efb12b..0e85c2b06 100644 --- a/documentation/docs/steps/transportRequestRelease.md +++ b/documentation/docs/steps/transportRequestRelease.md @@ -12,32 +12,88 @@ Releases a Transport Request for a Change Document on the Solution Manager. | `script` | yes | | | | `changeDocumentId` | yes | | | | `transportRequestId`| yes | | | -| `credentialsId` | yes | | | -| `endpoint` | yes | | | +| `changeManagement/changeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | +| `changeManagement/credentialsId` | yes | | | +| `changeManagement/endpoint` | yes | | | +| `changeManagement/git/from` | no | `origin/master` | | +| `changeManagement/git/to` | no | `HEAD` | | +| `changeManagement/git/format` | no | `%b` | see `git log --help` | * `script` - The common script environment of the Jenkinsfile running. Typically the reference to the script calling the pipeline step is provided with the `this` parameter, as in `script: this`. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for retrieving, for example, configuration parameters. * `changeDocumentId` - The id of the change document related to the transport request to release. * `transportRequestId` - The id of the transport request to release. -* `credentialsId` - The credentials to connect to the Solution Manager. -* `endpoint` - The address of the Solution Manager. +* `changeManagement/changeDocumentLabel` - A pattern used for identifying lines holding the change document id. +* `changeManagement/credentialsId` - The id of the credentials to connect to the Solution Manager. The credentials needs to be maintained on Jenkins. +* `changeManagement/endpoint` - The address of the Solution Manager. +* `changeManagement/git/from` - The starting point for retrieving the change document id +* `changeManagement/git/to` - The end point for retrieving the change document id +* `changeManagement/git/format` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. ## Step configuration -The following parameters can also be specified as step parameters using the global configuration file: +The step is configured using a customer configuration file provided as +resource in an custom shared library. -* `credentialsId` -* `endpoint` +``` +@Library('piper-library-os@master') _ + +// the shared lib containing the additional configuration +// needs to be configured in Jenkins +@Library(foo@master') __ + +// inside the shared lib denoted by 'foo' the additional configuration file +// needs to be located under 'resources' ('resoures/myConfig.yml') +prepareDefaultValues script: this, + customDefaults: 'myConfig.yml' +``` + +Example content of ```'resources/myConfig.yml'``` in branch ```'master'``` of the repository denoted by +```'foo'```: + +``` +general: + changeManagement: + changeDocumentLabel: 'ChangeDocument\s?:' + cmClientOpts: '-Djavax.net.ssl.trustStore=' + credentialsId: 'CM' + endpoint: 'https://example.org/cm' + git: + from: 'HEAD~1' + to: 'HEAD' + format: '%b' +``` + +The properties configured in section `'general/changeManagement'` are shared between all change managment related steps. + +The properties can also be configured on a per-step basis: + +``` + [...] + steps: + transportRequestRelease: + changeManagement: + endpoint: 'https://example.org/cm' + [...] +``` + +The parameters can also be provided when the step is invoked. For examples see below. ## Return value None. ## Exceptions -* `AbortException`: +* `IllegalArgumentException`: * If the change id is not provided. * If the transport request id is not provided. +* `AbortException`: * If the release of the transport request fails. ## Example ```groovy -transportRequestRelease script:this, changeDocumentId: '001', transportRequestId: '001' +transportRequestRelease script:this, + changeDocumentId: '001', + transportRequestId: '001', + changeManagement: [ + endpoint: 'https://example.org/cm' + ] ``` diff --git a/documentation/docs/steps/transportRequestUploadFile.md b/documentation/docs/steps/transportRequestUploadFile.md index 56087acbe..9a2acab31 100644 --- a/documentation/docs/steps/transportRequestUploadFile.md +++ b/documentation/docs/steps/transportRequestUploadFile.md @@ -14,44 +14,97 @@ Uploads a file to a Transport Request for a Change Document on the Solution Mana | `transportRequestId`| yes | | | | `applicationId` | yes | | | | `filePath` | yes | | | -| `credentialsId` | yes | | | -| `endpoint` | yes | | | -| `gitFrom` | no | `origin/master` | | -| `gitTo` | no | `HEAD` | | -| `gitChangeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | -| `gitFormat` | no | `%b` | see `git log --help` | +| `changeManagement/credentialsId` | yes | | | +| `changeManagement/endpoint` | yes | | | +| `changeManagement/git/from` | no | `origin/master` | | +| `changeManagement/git/to` | no | `HEAD` | | +| `changeManagement/changeDocumentLabel` | no | `ChangeDocument\s?:` | regex pattern | +| `changeManagement/git/format` | no | `%b` | see `git log --help` | * `script` - The common script environment of the Jenkinsfile running. Typically the reference to the script calling the pipeline step is provided with the `this` parameter, as in `script: this`. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for retrieving, for example, configuration parameters. * `changeDocumentId` - The id of the change document related to the transport request to release. * `transportRequestId` - The id of the transport request to release. * `applicationId` - The id of the application. * `filePath` - The path of the file to upload. -* `credentialsId` - The credentials to connect to the Solution Manager. -* `endpoint` - The address of the Solution Manager. -* `gitFrom` - The starting point for retrieving the change document id -* `gitTo` - The end point for retrieving the change document id -* `gitChangeDocumentLabel` - A pattern used for identifying lines holding the change document id. -* `gitFormat` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. +* `changeManagement/credentialsId` - The credentials to connect to the Solution Manager. +* `changeManagement/endpoint` - The address of the Solution Manager. +* `changeManagement/git/from` - The starting point for retrieving the change document id +* `changeManagement/git/to` - The end point for retrieving the change document id +* `changeManagement/changeDocumentLabel` - A pattern used for identifying lines holding the change document id. +* `changeManagement/git/format` - Specifies what part of the commit is scanned. By default the body of the commit message is scanned. + + ## Step configuration -The following parameters can also be specified as step parameters using the global configuration file: +The step is configured using a customer configuration file provided as +resource in an custom shared library. -* `credentialsId` -* `endpoint` +``` +@Library('piper-library-os@master') _ + +// the shared lib containing the additional configuration +// needs to be configured in Jenkins +@Library(foo@master') __ + +// inside the shared lib denoted by 'foo' the additional configuration file +// needs to be located under 'resources' ('resoures/myConfig.yml') +prepareDefaultValues script: this, + customDefaults: 'myConfig.yml' +``` + +Example content of ```'resources/myConfig.yml'``` in branch ```'master'``` of the repository denoted by +```'foo'```: + +``` +general: + changeManagement: + changeDocumentLabel: 'ChangeDocument\s?:' + cmClientOpts: '-Djavax.net.ssl.trustStore=' + credentialsId: 'CM' + endpoint: 'https://example.org/cm' + git: + from: 'HEAD~1' + to: 'HEAD' + format: '%b' +``` + +The properties configured in section `'general/changeManagement'` are shared between all change managment related steps. + +The properties can also be configured on a per-step basis: + +``` + [...] + steps: + transportRequestUploadFile: + applicationId: 'FOO' + changeManagement: + endpoint: 'https://example.org/cm' + [...] +``` + +The parameters can also be provided when the step is invoked. For examples see below. ## Return value None. ## Exceptions -* `AbortException`: +* `IllegalArgumentException`: * If the change id is not provided. * If the transport request id is not provided. * If the application id is not provided. * If the file path is not provided. +* `AbortException`: * If the upload fails. ## Example ```groovy -transportRequestUploadFile script:this, changeDocumentId: '001', transportRequestId: '001', applicationId: '001', filePath: '/path' +transportRequestUploadFile script:this, + changeDocumentId: '001', + transportRequestId: '001', + applicationId: '001', + filePath: '/path', + changeManagement:[ + endpoint: 'https://example.org/cm' + ] ``` diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index cf0ecc851..5be225360 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -1,7 +1,14 @@ #Project Setup general: productiveBranch: 'master' - + changeManagement: + changeDocumentLabel: 'ChangeDocument\s?:' + clientOpts: '' + credentialsId: 'CM' + git: + from: 'origin/master' + to: 'HEAD' + format: '%b' #Steps Specific Configuration steps: artifactSetVersion: @@ -174,27 +181,8 @@ steps: archive: false active: false checkChangeInDevelopment: - credentialsId: 'CM' failIfStatusIsNotInDevelopment: true - gitFrom: 'origin/master' - gitTo: 'HEAD' - gitChangeDocumentLabel: 'ChangeDocument\s?:' - gitFormat: '%b' transportRequestCreate: - credentialsId: 'CM' - gitFrom: 'origin/master' - gitTo: 'HEAD' - gitChangeDocumentLabel: 'ChangeDocument\s?:' - gitFormat: '%b' + developmentSystemId: null transportRequestUploadFile: - credentialsId: 'CM' - gitFrom: 'origin/master' - gitTo: 'HEAD' - gitChangeDocumentLabel: 'ChangeDocument\s?:' - gitFormat: '%b' transportRequestRelease: - credentialsId: 'CM' - gitFrom: 'origin/master' - gitTo: 'HEAD' - gitChangeDocumentLabel: 'ChangeDocument\s?:' - gitFormat: '%b' diff --git a/test/groovy/CheckChangeInDevelopmentTest.groovy b/test/groovy/CheckChangeInDevelopmentTest.groovy index d030dabe1..b43497798 100644 --- a/test/groovy/CheckChangeInDevelopmentTest.groovy +++ b/test/groovy/CheckChangeInDevelopmentTest.groovy @@ -50,7 +50,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { ChangeManagement cm = getChangeManagementUtils(true) boolean inDevelopment = jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) assert inDevelopment @@ -59,7 +59,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { endpoint: 'https://example.org/cm', userName: 'defaultUser', password: '********', - cmclientOpts: null + cmclientOpts: '' ] } @@ -72,7 +72,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { ChangeManagement cm = getChangeManagementUtils(false) jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) } @Test @@ -81,7 +81,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { ChangeManagement cm = getChangeManagementUtils(false) boolean inDevelopment = jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm', + changeManagement: [endpoint: 'https://example.org/cm'], failIfStatusIsNotInDevelopment: false) assert !inDevelopment } @@ -93,7 +93,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { jsr.step.checkChangeInDevelopment( changeDocumentId: '42', cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) assert cmUtilReceivedParams.changeId == '42' } @@ -104,7 +104,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement : [endpoint: 'https://example.org/cm']) assert cmUtilReceivedParams.changeId == '0815' } @@ -128,7 +128,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) } @Test @@ -142,7 +142,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { ChangeManagement cm = getChangeManagementUtils(false, null) jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) } @Test @@ -156,7 +156,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { ChangeManagement cm = getChangeManagementUtils(false, '') jsr.step.checkChangeInDevelopment( cmUtils: cm, - endpoint: 'https://example.org/cm') + changeManagement: [endpoint: 'https://example.org/cm']) } private ChangeManagement getChangeManagementUtils(boolean inDevelopment, String changeDocumentId = '001') { diff --git a/test/groovy/TransportRequestCreateTest.groovy b/test/groovy/TransportRequestCreateTest.groovy index 269b8ec98..56194643a 100644 --- a/test/groovy/TransportRequestCreateTest.groovy +++ b/test/groovy/TransportRequestCreateTest.groovy @@ -47,12 +47,16 @@ public class TransportRequestCreateTest extends BasePiperTest { helper.registerAllowedMethod('sh', [Map], { Map m -> return 0 }) - nullScript.commonPipelineEnvironment.configuration = [steps: - [transportRequestCreate: + nullScript.commonPipelineEnvironment.configuration = [general: + [changeManagement: [ credentialsId: 'CM', endpoint: 'https://example.org/cm', - clientOpts: '-DmyProp=myVal' + clientOpts: '-DmyProp=myVal', + changeDocumentLabel: 'ChangeId\\s?:', + git: [from: 'origin/master', + to: 'HEAD', + format: '%b'] ] ] ] diff --git a/test/groovy/TransportRequestReleaseTest.groovy b/test/groovy/TransportRequestReleaseTest.groovy index 619556c76..202d289a0 100644 --- a/test/groovy/TransportRequestReleaseTest.groovy +++ b/test/groovy/TransportRequestReleaseTest.groovy @@ -44,8 +44,8 @@ public class TransportRequestReleaseTest extends BasePiperTest { helper.registerAllowedMethod('sh', [Map], { Map m -> return 0 }) - nullScript.commonPipelineEnvironment.configuration = [steps: - [transportRequestRelease: + nullScript.commonPipelineEnvironment.configuration = [general: + [changeManagement: [ credentialsId: 'CM', endpoint: 'https://example.org/cm' diff --git a/test/groovy/TransportRequestUploadFileTest.groovy b/test/groovy/TransportRequestUploadFileTest.groovy index 08ab99eee..b7fa4b103 100644 --- a/test/groovy/TransportRequestUploadFileTest.groovy +++ b/test/groovy/TransportRequestUploadFileTest.groovy @@ -47,8 +47,8 @@ public class TransportRequestUploadFileTest extends BasePiperTest { helper.registerAllowedMethod('sh', [Map], { Map m -> return 0 }) - nullScript.commonPipelineEnvironment.configuration = [steps: - [transportRequestUploadFile: + nullScript.commonPipelineEnvironment.configuration = [general: + [changeManagement: [ credentialsId: 'CM', endpoint: 'https://example.org/cm' diff --git a/vars/checkChangeInDevelopment.groovy b/vars/checkChangeInDevelopment.groovy index 64b2c6b49..766734317 100644 --- a/vars/checkChangeInDevelopment.groovy +++ b/vars/checkChangeInDevelopment.groovy @@ -10,14 +10,8 @@ import com.sap.piper.cm.ChangeManagementException @Field def STEP_NAME = 'checkChangeInDevelopment' @Field Set stepConfigurationKeys = [ - 'cmClientOpts', - 'credentialsId', - 'endpoint', - 'failIfStatusIsNotInDevelopment', - 'gitFrom', - 'gitTo', - 'gitChangeDocumentLabel', - 'gitFormat' + 'changeManagement', + 'failIfStatusIsNotInDevelopment' ] @Field Set parameterKeys = stepConfigurationKeys.plus('changeDocumentId') @@ -40,6 +34,17 @@ def call(parameters = [:]) { .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, stepConfigurationKeys) .mixinStepConfig(script.commonPipelineEnvironment, stepConfigurationKeys) .mixin(parameters, parameterKeys) + // for the following parameters we expect defaults + .withMandatoryProperty('changeManagement/changeDocumentLabel') + .withMandatoryProperty('changeManagement/clientOpts') + .withMandatoryProperty('changeManagement/credentialsId') + .withMandatoryProperty('changeManagement/git/from') + .withMandatoryProperty('changeManagement/git/to') + .withMandatoryProperty('changeManagement/git/format') + .withMandatoryProperty('failIfStatusIsNotInDevelopment') + // for the following parameters we expect a value provided from outside + .withMandatoryProperty('changeManagement/endpoint') + Map configuration = configHelper.use() @@ -51,15 +56,15 @@ def call(parameters = [:]) { } else { - echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.gitFrom}, to: ${configuration.gitTo}]." + - "Searching for pattern '${configuration.gitChangeDocumentLabel}'. Searching with format '${configuration.gitFormat}'." + echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.changeManagement.git.from}, to: ${configuration.changeManagement.git.to}]." + + "Searching for pattern '${configuration.changeManagement.changeDocumentLabel}'. Searching with format '${configuration.changeManagement.git.format}'." try { changeId = cm.getChangeDocumentId( - configuration.gitFrom, - configuration.gitTo, - configuration.gitChangeDocumentLabel, - configuration.gitFormat + configuration.changeManagement.git.from, + configuration.changeManagement.git.to, + configuration.changeManagement.changeDocumentLabel, + configuration.changeManagement.git.format ) if(changeId?.trim()) { echo "[INFO] ChangeDocumentId '${changeId}' retrieved from commit history" @@ -70,11 +75,10 @@ def call(parameters = [:]) { } configuration = configHelper.mixin([changeDocumentId: changeId?.trim() ?: null], ['changeDocumentId'] as Set) - .withMandatoryProperty('endpoint') .withMandatoryProperty('changeDocumentId', "No changeDocumentId provided. Neither via parameter 'changeDocumentId' " + - "nor via label '${configuration.gitChangeDocumentLabel}' in commit range " + - "[from: ${configuration.gitFrom}, to: ${configuration.gitTo}].") + "nor via label '${configuration.changeManagement.changeDocumentLabel}' in commit range " + + "[from: ${configuration.changeManagement.git.from}, to: ${configuration.changeManagement.git.to}].") .use() boolean isInDevelopment @@ -82,16 +86,16 @@ def call(parameters = [:]) { echo "[INFO] Checking if change document '${configuration.changeDocumentId}' is in development." withCredentials([usernamePassword( - credentialsId: configuration.credentialsId, + credentialsId: configuration.changeManagement.credentialsId, passwordVariable: 'password', usernameVariable: 'username')]) { try { isInDevelopment = cm.isChangeInDevelopment(configuration.changeDocumentId, - configuration.endpoint, + configuration.changeManagement.endpoint, username, password, - configuration.cmClientOpts) + configuration.changeManagement.clientOpts) } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } diff --git a/vars/transportRequestCreate.groovy b/vars/transportRequestCreate.groovy index c0db4852c..88899b93d 100644 --- a/vars/transportRequestCreate.groovy +++ b/vars/transportRequestCreate.groovy @@ -12,16 +12,11 @@ import hudson.AbortException @Field def STEP_NAME = 'transportRequestCreate' @Field Set stepConfigurationKeys = [ - 'credentialsId', - 'clientOpts', - 'endpoint', - 'gitFrom', - 'gitTo', - 'gitChangeDocumentLabel', - 'gitFormat' + 'changeManagement', + 'developmentSystemId' ] -@Field Set parameterKeys = stepConfigurationKeys.plus(['changeDocumentId', 'developmentSystemId']) +@Field Set parameterKeys = stepConfigurationKeys.plus(['changeDocumentId']) @Field generalConfigurationKeys = stepConfigurationKeys @@ -39,7 +34,12 @@ def call(parameters = [:]) { .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, stepConfigurationKeys) .mixinStepConfig(script.commonPipelineEnvironment, stepConfigurationKeys) .mixin(parameters, parameterKeys) - .withMandatoryProperty('endpoint') + .withMandatoryProperty('changeManagement/clientOpts') + .withMandatoryProperty('changeManagement/credentialsId') + .withMandatoryProperty('changeManagement/endpoint') + .withMandatoryProperty('changeManagement/git/from') + .withMandatoryProperty('changeManagement/git/to') + .withMandatoryProperty('changeManagement/git/format') .withMandatoryProperty('developmentSystemId') Map configuration = configHelper.use() @@ -52,15 +52,16 @@ def call(parameters = [:]) { } else { - echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.gitFrom}, to: ${configuration.gitTo}]." + - "Searching for pattern '${configuration.gitChangeDocumentLabel}'. Searching with format '${configuration.gitFormat}'." + echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.changeManagement.git.from}, to: ${configuration.changeManagement.git.to}]." + + "Searching for pattern '${configuration.changeDocumentLabel}'. Searching with format '${configuration.changeManagement.git.format}'." try { + changeDocumentId = cm.getChangeDocumentId( - configuration.gitFrom, - configuration.gitTo, - configuration.gitChangeDocumentLabel, - configuration.gitFormat + configuration.changeManagement.git.from, + configuration.changeManagement.git.to, + configuration.changeManagement.changeDocumentLabel, + configuration.changeManagement.git.format ) echo "[INFO] ChangeDocumentId '${changeDocumentId}' retrieved from commit history" @@ -79,17 +80,17 @@ def call(parameters = [:]) { echo "[INFO] Creating transport request for change document '${configuration.changeDocumentId}' and development system '${configuration.developmentSystemId}'." withCredentials([usernamePassword( - credentialsId: configuration.credentialsId, + credentialsId: configuration.changeManagement.credentialsId, passwordVariable: 'password', usernameVariable: 'username')]) { try { transportRequestId = cm.createTransportRequest(configuration.changeDocumentId, configuration.developmentSystemId, - configuration.endpoint, + configuration.changeManagement.endpoint, username, password, - configuration.clientOpts) + configuration.changeManagement.clientOpts) } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } diff --git a/vars/transportRequestRelease.groovy b/vars/transportRequestRelease.groovy index 33fde5924..d6d595a3a 100644 --- a/vars/transportRequestRelease.groovy +++ b/vars/transportRequestRelease.groovy @@ -12,9 +12,7 @@ import hudson.AbortException @Field def STEP_NAME = 'transportRequestRelease' @Field Set stepConfigurationKeys = [ - 'credentialsId', - 'cmClientOpts', - 'endpoint' + 'changeManagement' ] @Field Set parameterKeys = stepConfigurationKeys.plus([ @@ -40,23 +38,25 @@ def call(parameters = [:]) { .mixin(parameters, parameterKeys) .withMandatoryProperty('changeDocumentId') .withMandatoryProperty('transportRequestId') - .withMandatoryProperty('endpoint') + .withMandatoryProperty('changeManagement/clientOpts') + .withMandatoryProperty('changeManagement/credentialsId') + .withMandatoryProperty('changeManagement/endpoint') .use() echo "[INFO] Closing transport request '${configuration.transportRequestId}' for change document '${configuration.changeDocumentId}'." withCredentials([usernamePassword( - credentialsId: configuration.credentialsId, + credentialsId: configuration.changeManagement.credentialsId, passwordVariable: 'password', usernameVariable: 'username')]) { try { cm.releaseTransportRequest(configuration.changeDocumentId, configuration.transportRequestId, - configuration.endpoint, + configuration.changeManagement.endpoint, username, password, - configuration.cmClientOpts) + configuration.changeManagement.clientOpts) } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } diff --git a/vars/transportRequestUploadFile.groovy b/vars/transportRequestUploadFile.groovy index 55fc4c978..9ef4c29e3 100644 --- a/vars/transportRequestUploadFile.groovy +++ b/vars/transportRequestUploadFile.groovy @@ -12,13 +12,7 @@ import hudson.AbortException @Field def STEP_NAME = 'transportRequestUploadFile' @Field Set generalConfigurationKeys = [ - 'credentialsId', - 'cmClientOpts', - 'endpoint', - 'gitFrom', - 'gitTo', - 'gitChangeDocumentLabel', - 'gitFormat' + 'changeManagement', ] @Field Set parameterKeys = generalConfigurationKeys.plus([ @@ -43,10 +37,16 @@ def call(parameters = [:]) { .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, stepConfigurationKeys) .mixinStepConfig(script.commonPipelineEnvironment, stepConfigurationKeys) .mixin(parameters, parameterKeys) - .withMandatoryProperty('endpoint') - .withMandatoryProperty('transportRequestId') .withMandatoryProperty('applicationId') + .withMandatoryProperty('changeManagement/changeDocumentLabel') + .withMandatoryProperty('changeManagement/clientOpts') + .withMandatoryProperty('changeManagement/credentialsId') + .withMandatoryProperty('changeManagement/endpoint') + .withMandatoryProperty('changeManagement/git/from') + .withMandatoryProperty('changeManagement/git/to') + .withMandatoryProperty('changeManagement/git/format') .withMandatoryProperty('filePath') + .withMandatoryProperty('transportRequestId') Map configuration = configHelper.use() @@ -58,15 +58,15 @@ def call(parameters = [:]) { } else { - echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.gitFrom}, to: ${configuration.gitTo}]." + - "Searching for pattern '${configuration.gitChangeDocumentLabel}'. Searching with format '${configuration.gitFormat}'." + echo "[INFO] Retrieving ChangeDocumentId from commit history [from: ${configuration.changeManagement.git.from}, to: ${configuration.changeManagement.git.to}]." + + "Searching for pattern '${configuration.changeManagement.changeDocumentLabel}'. Searching with format '${configuration.changeManagement.git.format}'." try { changeDocumentId = cm.getChangeDocumentId( - configuration.gitFrom, - configuration.gitTo, - configuration.gitChangeDocumentLabel, - configuration.gitFormat + configuration.changeManagement.git.from, + configuration.changeManagement.git.to, + configuration.changeManagement.changeDocumentLabel, + configuration.changeManagement.git.format ) echo "[INFO] ChangeDocumentId '${changeDocumentId}' retrieved from commit history" @@ -85,7 +85,7 @@ def call(parameters = [:]) { echo "[INFO] Uploading file '${configuration.filePath}' to transport request '${configuration.transportRequestId}' of change document '${configuration.changeDocumentId}'." withCredentials([usernamePassword( - credentialsId: configuration.credentialsId, + credentialsId: configuration.changeManagement.credentialsId, passwordVariable: 'password', usernameVariable: 'username')]) { @@ -94,10 +94,10 @@ def call(parameters = [:]) { configuration.transportRequestId, configuration.applicationId, configuration.filePath, - configuration.endpoint, + configuration.changeManagement.endpoint, username, password, - configuration.cmClientOpts) + configuration.changeManagement.clientOpts) } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } From 1e0fa0799e5244766a8f58230a7e853dce43fb17 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 19 Jul 2018 10:06:40 +0200 Subject: [PATCH 03/24] [fix] Remove early check for mandatory param 'changeDocumentId' otherwise we will never try to read the change document id from commit history. --- test/groovy/TransportRequestReleaseTest.groovy | 2 +- vars/transportRequestRelease.groovy | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/groovy/TransportRequestReleaseTest.groovy b/test/groovy/TransportRequestReleaseTest.groovy index f6fe2e3bb..f498c0479 100644 --- a/test/groovy/TransportRequestReleaseTest.groovy +++ b/test/groovy/TransportRequestReleaseTest.groovy @@ -57,7 +57,7 @@ public class TransportRequestReleaseTest extends BasePiperTest { } thrown.expect(IllegalArgumentException) - thrown.expectMessage("ERROR - NO VALUE AVAILABLE FOR changeDocumentId") + thrown.expectMessage("Change document id not provided (parameter: 'changeDocumentId' or via commit history).") jsr.step.call(script: nullScript, transportRequestId: '001', cmUtils: cm) } diff --git a/vars/transportRequestRelease.groovy b/vars/transportRequestRelease.groovy index defa1e8db..01ce6ab79 100644 --- a/vars/transportRequestRelease.groovy +++ b/vars/transportRequestRelease.groovy @@ -43,7 +43,6 @@ def call(parameters = [:]) { .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, stepConfigurationKeys) .mixinStepConfig(script.commonPipelineEnvironment, stepConfigurationKeys) .mixin(parameters, parameterKeys) - .withMandatoryProperty('changeDocumentId') .withMandatoryProperty('endpoint') Map configuration = configHelper.use() From 43a9dcbcfd05f583c5927eedc3c2369081bb67ba Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Thu, 19 Jul 2018 10:26:34 +0200 Subject: [PATCH 04/24] Do not fail immediatly in case commit history does not contain a changeDocumentId we emit a log message and fail later at withManadotoryProperty check. --- test/groovy/CheckChangeInDevelopmentTest.groovy | 5 +++-- vars/checkChangeInDevelopment.groovy | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/groovy/CheckChangeInDevelopmentTest.groovy b/test/groovy/CheckChangeInDevelopmentTest.groovy index 6689843ed..b7e9ded1c 100644 --- a/test/groovy/CheckChangeInDevelopmentTest.groovy +++ b/test/groovy/CheckChangeInDevelopmentTest.groovy @@ -103,8 +103,9 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { @Test public void changeDocumentIdRetrievalFailsTest() { - thrown.expect(AbortException) - thrown.expectMessage('Something went wrong') + thrown.expect(IllegalArgumentException) + thrown.expectMessage("No changeDocumentId provided. Neither via parameter 'changeDocumentId' nor via " + + "label 'ChangeDocument\\s?:' in commit range [from: origin/master, to: HEAD].") ChangeManagement cm = new ChangeManagement(nullScript, null) { diff --git a/vars/checkChangeInDevelopment.groovy b/vars/checkChangeInDevelopment.groovy index 64b2c6b49..afc44650a 100644 --- a/vars/checkChangeInDevelopment.groovy +++ b/vars/checkChangeInDevelopment.groovy @@ -65,7 +65,7 @@ def call(parameters = [:]) { echo "[INFO] ChangeDocumentId '${changeId}' retrieved from commit history" } } catch(ChangeManagementException ex) { - throw new AbortException(ex.getMessage()) + echo "[WARN] Cannot retrieve changeDocumentId from commit history: ${ex.getMessage()}." } } From 411fd7fe626d37b1343de135394da51dd9fedff8 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Mon, 16 Jul 2018 13:29:00 +0200 Subject: [PATCH 05/24] Access to nested properties yaml configuration supports nested properties. With this change we can read those nested properties. --- src/com/sap/piper/ConfigurationHelper.groovy | 27 +++++++++++-- .../sap/piper/ConfigurationHelperTest.groovy | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index ee78dc459..04eecf048 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -74,10 +74,7 @@ class ConfigurationHelper implements Serializable { } def getConfigProperty(key) { - if (config[key] != null && config[key].class == String) { - return config[key].trim() - } - return config[key] + return getConfigPropertyNested(config, key) } def getConfigProperty(key, defaultValue) { @@ -88,6 +85,28 @@ class ConfigurationHelper implements Serializable { return value } + private getConfigPropertyNested(Map config, key) { + + def separator = '/' + + // reason for cast to CharSequence: String#tokenize(./.) causes a deprecation warning. + List parts = (key in String) ? (key as CharSequence).tokenize(separator) : ([key] as List) + + 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)) + } + + if (config[parts.head()].class == String) { + return (config[parts.head()] as String).trim() + } + } + + + return config[parts.head()] + } + def isPropertyDefined(key){ def value = getConfigProperty(key) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 478acb4a1..7e180051a 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -3,7 +3,10 @@ package com.sap.piper import groovy.test.GroovyAssert import static org.hamcrest.Matchers.* +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat +import org.hamcrest.Matchers import org.junit.Assert import org.junit.Rule import org.junit.Test @@ -29,6 +32,42 @@ class ConfigurationHelperTest { Assert.assertFalse(configuration.isPropertyDefined('something')) } + @Test + void testGetPropertyNestedLeafNodeIsString() { + def configuration = new ConfigurationHelper([a:[b: 'c']]) + assertThat(configuration.getConfigProperty('a/b'), is('c')) + } + + @Test + void testGetPropertyNestedLeafNodeIsMap() { + def configuration = new ConfigurationHelper([a:[b: [c: 'd']]]) + assertThat(configuration.getConfigProperty('a/b'), is([c: 'd'])) + } + + @Test + void testGetPropertyNestedPathNotFound() { + def configuration = new ConfigurationHelper([a:[b: 'c']]) + assertThat(configuration.getConfigProperty('a/c'), is((nullValue()))) + } + + @Test + void testGetPropertyNestedPathStartsWithTokenizer() { + def configuration = new ConfigurationHelper([k:'v']) + assertThat(configuration.getConfigProperty('/k'), is(('v'))) + } + + @Test + void testGetPropertyNestedPathEndsWithTokenizer() { + def configuration = new ConfigurationHelper([k:'v']) + assertThat(configuration.getConfigProperty('k/'), is(('v'))) + } + + @Test + void testGetPropertyNestedPathManyTokenizer() { + def configuration = new ConfigurationHelper([k1:[k2 : 'v']]) + assertThat(configuration.getConfigProperty('///k1/////k2///'), is(('v'))) + } + @Test void testIsPropertyDefined() { def configuration = new ConfigurationHelper(getConfiguration()) From c3c7d8b14280a7ee1304bc09617159e057f2f814 Mon Sep 17 00:00:00 2001 From: Roland Stengel Date: Tue, 24 Jul 2018 12:52:04 +0200 Subject: [PATCH 06/24] remove the tool descriptor from neo deploy --- test/groovy/NeoDeployTest.groovy | 88 ++++---------------------------- vars/neoDeploy.groovy | 25 ++++----- 2 files changed, 18 insertions(+), 95 deletions(-) diff --git a/test/groovy/NeoDeployTest.groovy b/test/groovy/NeoDeployTest.groovy index 66745bade..3dac6f73f 100644 --- a/test/groovy/NeoDeployTest.groovy +++ b/test/groovy/NeoDeployTest.groovy @@ -100,7 +100,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -118,7 +118,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -142,7 +142,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") .hasSingleQuotedOption('host', 'configuration-frwk\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'configurationFrwkUser123') .hasOption('synchronous', '') @@ -173,7 +173,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -183,68 +183,6 @@ class NeoDeployTest extends BasePiperTest { } - @Test - void neoHomeNotSetTest() { - - helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) - - jsr.step.call(script: nullScript, - archivePath: archiveName - ) - - assert jscr.shell.find { c -> c.contains('"neo.sh" deploy-mta') } - assert jlr.log.contains('SAP Cloud Platform Console Client is on PATH.') - assert jlr.log.contains("Using SAP Cloud Platform Console Client 'neo.sh'.") - } - - - @Test - void neoHomeAsParameterTest() { - - helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) - - jsr.step.call(script: nullScript, - archivePath: archiveName, - neoCredentialsId: 'myCredentialsId', - neoHome: '/param/neo' - ) - - assert jscr.shell.find{ c -> c = "\"/param/neo/tools/neo.sh\" deploy-mta" } - assert jlr.log.contains("SAP Cloud Platform Console Client home '/param/neo' retrieved from configuration.") - assert jlr.log.contains("Using SAP Cloud Platform Console Client '/param/neo/tools/neo.sh'.") - } - - - @Test - void neoHomeFromEnvironmentTest() { - - jsr.step.call(script: nullScript, - archivePath: archiveName - ) - - assert jscr.shell.find { c -> c.contains("\"/opt/neo/tools/neo.sh\" deploy-mta")} - assert jlr.log.contains("SAP Cloud Platform Console Client home '/opt/neo' retrieved from environment.") - assert jlr.log.contains("Using SAP Cloud Platform Console Client '/opt/neo/tools/neo.sh'.") - } - - - @Test - void neoHomeFromCustomStepConfigurationTest() { - - helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) - - nullScript.commonPipelineEnvironment.configuration = [steps:[neoDeploy: [host: 'test.deploy.host.com', account: 'trialuser123', neoHome: '/config/neo']]] - - jsr.step.call(script: nullScript, - archivePath: archiveName - ) - - assert jscr.shell.find { c -> c = "\"/config/neo/tools/neo.sh\" deploy-mta"} - assert jlr.log.contains("SAP Cloud Platform Console Client home '/config/neo' retrieved from configuration.") - assert jlr.log.contains("Using SAP Cloud Platform Console Client '/config/neo/tools/neo.sh'.") - } - - @Test void archiveNotProvidedTest() { @@ -283,7 +221,7 @@ class NeoDeployTest extends BasePiperTest { jsr.step.call(script: nullScript, archivePath: archiveName, deployMode: 'mta') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -306,7 +244,7 @@ class NeoDeployTest extends BasePiperTest { archivePath: warArchiveName) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasSingleQuotedOption('application', 'testApp') @@ -332,7 +270,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" rolling-update") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh rolling-update") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasSingleQuotedOption('application', 'testApp') @@ -358,7 +296,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy") .hasArgument("config.properties") .hasSingleQuotedOption('user', 'defaultUser') .hasSingleQuotedOption('password', '\\*\\*\\*\\*\\*\\*\\*\\*') @@ -379,7 +317,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" rolling-update") + new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh rolling-update") .hasArgument('config.properties') .hasSingleQuotedOption('user', 'defaultUser') .hasSingleQuotedOption('password', '\\*\\*\\*\\*\\*\\*\\*\\*') @@ -536,12 +474,8 @@ class NeoDeployTest extends BasePiperTest { if(m.script.contains('JAVA_HOME')) { return '/opt/java' - } else if(m.script.contains('NEO_HOME')) { - return '/opt/neo' } else if (m.script.contains('which java')) { return 0 - } else if (m.script.contains('which neo')) { - return 0 } else { return 0 } @@ -551,12 +485,8 @@ class NeoDeployTest extends BasePiperTest { if(m.script.contains('JAVA_HOME')) { return '' - } else if(m.script.contains('NEO_HOME')) { - return '' } else if (m.script.contains('which java')) { return 0 - } else if (m.script.contains('which neo')) { - return 0 } else { return 0 } diff --git a/vars/neoDeploy.groovy b/vars/neoDeploy.groovy index 8cfec18df..4aac3dab6 100644 --- a/vars/neoDeploy.groovy +++ b/vars/neoDeploy.groovy @@ -22,7 +22,6 @@ def call(parameters = [:]) { 'dockerOptions', 'host', 'neoCredentialsId', - 'neoHome', 'propertiesFile', 'runtime', 'runtimeVersion', @@ -36,8 +35,7 @@ def call(parameters = [:]) { 'dockerImage', 'dockerOptions', 'host', - 'neoCredentialsId', - 'neoHome' + 'neoCredentialsId' ] handlePipelineStepErrors (stepName: stepName, stepParameters: parameters) { @@ -148,26 +146,22 @@ def call(parameters = [:]) { deployAccount = utils.getMandatoryParameter(configuration, 'account') } - def neo = new ToolDescriptor('SAP Cloud Platform Console Client', 'NEO_HOME', 'neoHome', '/tools/', 'neo.sh', null, 'version') - def neoExecutable = neo.getToolExecutable(this, configuration) - def neoDeployScript = """#!/bin/bash - "${neoExecutable}" ${warAction} \ + def neoCmdArgs = """${warAction} \ --source "${archivePath}" \ - """ - + """ if (deployMode in ['mta', 'warParams']) { - neoDeployScript += + neoCmdArgs += """--host '${deployHost}' \ --account '${deployAccount}' \ """ } if (deployMode == 'mta') { - neoDeployScript += "--synchronous" + neoCmdArgs += "--synchronous" } if (deployMode == 'warParams') { - neoDeployScript += + neoCmdArgs += """--application '${applicationName}' \ --runtime '${runtime}' \ --runtime-version '${runtimeVersion}' \ @@ -175,7 +169,7 @@ def call(parameters = [:]) { } if (deployMode == 'warPropertiesFile') { - neoDeployScript += + neoCmdArgs += """${propertiesFile}""" } @@ -192,12 +186,11 @@ def call(parameters = [:]) { dockerEnvVars: configuration.get('dockerEnvVars'), dockerOptions: configuration.get('dockerOptions')) { - neo.verify(this, configuration) - def java = new ToolDescriptor('Java', 'JAVA_HOME', '', '/bin/', 'java', '1.8.0', '-version 2>&1') java.verify(this, configuration) - sh """${neoDeployScript} \ + sh """#!/bin/bash + neo.sh ${neoCmdArgs} \ ${credentials} """ } From b7e355bca8fac4ff1607d8750afe9d2d9e698deb Mon Sep 17 00:00:00 2001 From: Roland Stengel Date: Mon, 23 Jul 2018 12:18:42 +0200 Subject: [PATCH 07/24] config neo docker image --- resources/default_pipeline_environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index 4c2f153a7..e76ffb681 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -85,6 +85,7 @@ steps: buildTarget: 'NEO' mtaJarLocation: 'mta.jar' neoDeploy: + dockerImage: 's4sdk/docker-neo-cli' deployMode: 'mta' warAction: 'deploy' vmSize: 'lite' From 0a86c8534dad25e06bc1a4f1114cfc3689188b5a Mon Sep 17 00:00:00 2001 From: Florian Wilhelm <2292245+fwilhe@users.noreply.github.com> Date: Thu, 26 Jul 2018 16:03:05 +0200 Subject: [PATCH 08/24] Improve check for batch mode in mavenExecute (#226) The old naive check fails if an argument starts with '-B'. Now we use a regular expression, which should correctly match if batch mode was already supplied, and add it if not. --- test/groovy/MavenExecuteTest.groovy | 34 +++++++++++++++++++++++++++-- vars/mavenExecute.groovy | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/test/groovy/MavenExecuteTest.groovy b/test/groovy/MavenExecuteTest.groovy index 01364bd6e..80fc3e7bf 100644 --- a/test/groovy/MavenExecuteTest.groovy +++ b/test/groovy/MavenExecuteTest.groovy @@ -7,9 +7,15 @@ import util.JenkinsShellCallRule import util.JenkinsStepRule import util.Rules +import static org.hamcrest.Matchers.allOf +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.not import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat import static org.junit.Assert.assertTrue + class MavenExecuteTest extends BasePiperTest { Map dockerParameters @@ -63,10 +69,34 @@ class MavenExecuteTest extends BasePiperTest { @Test void testMavenCommandForwardsDockerOptions() throws Exception { - jsr.step.mavenExecute(script: nullScript, goals: 'clean install') assertEquals('maven:3.5-jdk-7', jder.dockerParams.dockerImage) - assert jscr.shell[0] == 'mvn --batch-mode -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn clean install' + assertEquals('mvn --batch-mode -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn clean install', jscr.shell[0]) + } + + @Test + void testMavenCommandWithShortBatchModeFlag() throws Exception { + jsr.step.mavenExecute(script: nullScript, goals: 'clean install', flags: '-B') + assertEquals('maven:3.5-jdk-7', jder.dockerParams.dockerImage) + + assertEquals('mvn -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn clean install', jscr.shell[0]) + } + + @Test + void testMavenCommandWithFalsePositiveMinusBFlag() throws Exception { + jsr.step.mavenExecute(script: nullScript, goals: 'clean install', flags: '-Blah') + assertEquals('maven:3.5-jdk-7', jder.dockerParams.dockerImage) + + assertThat(jscr.shell[0], + allOf(containsString('-Blah'), + containsString('--batch-mode'))) + } + + @Test + void testMavenCommandWithBatchModeMultiline() throws Exception { + jsr.step.mavenExecute(script: nullScript, goals: 'clean install', flags: ('''-B\\ + |--show-version''' as CharSequence).stripMargin()) + assertThat(jscr.shell[0], not(containsString('--batch-mode'))) } } diff --git a/vars/mavenExecute.groovy b/vars/mavenExecute.groovy index b6bd2a781..bcd7b4c66 100644 --- a/vars/mavenExecute.groovy +++ b/vars/mavenExecute.groovy @@ -67,7 +67,7 @@ def call(Map parameters = [:]) { } // Always use Maven's batch mode - if (!(command.contains('-B') || command.contains('--batch-mode'))){ + if (!(command =~ /--batch-mode|-B(?=\s)|-B\\|-B$/)) { command += ' --batch-mode' } From d49a9ad68cefcbaac10d8fc016244265ef1e19aa Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Fri, 27 Jul 2018 14:09:08 +0200 Subject: [PATCH 09/24] Revert "remove the tool descriptor from neo deploy" This reverts commit c3c7d8b14280a7ee1304bc09617159e057f2f814. --- test/groovy/NeoDeployTest.groovy | 88 ++++++++++++++++++++++++++++---- vars/neoDeploy.groovy | 25 +++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/test/groovy/NeoDeployTest.groovy b/test/groovy/NeoDeployTest.groovy index 3dac6f73f..66745bade 100644 --- a/test/groovy/NeoDeployTest.groovy +++ b/test/groovy/NeoDeployTest.groovy @@ -100,7 +100,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -118,7 +118,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -142,7 +142,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") .hasSingleQuotedOption('host', 'configuration-frwk\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'configurationFrwkUser123') .hasOption('synchronous', '') @@ -173,7 +173,7 @@ class NeoDeployTest extends BasePiperTest { ) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -183,6 +183,68 @@ class NeoDeployTest extends BasePiperTest { } + @Test + void neoHomeNotSetTest() { + + helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) + + jsr.step.call(script: nullScript, + archivePath: archiveName + ) + + assert jscr.shell.find { c -> c.contains('"neo.sh" deploy-mta') } + assert jlr.log.contains('SAP Cloud Platform Console Client is on PATH.') + assert jlr.log.contains("Using SAP Cloud Platform Console Client 'neo.sh'.") + } + + + @Test + void neoHomeAsParameterTest() { + + helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) + + jsr.step.call(script: nullScript, + archivePath: archiveName, + neoCredentialsId: 'myCredentialsId', + neoHome: '/param/neo' + ) + + assert jscr.shell.find{ c -> c = "\"/param/neo/tools/neo.sh\" deploy-mta" } + assert jlr.log.contains("SAP Cloud Platform Console Client home '/param/neo' retrieved from configuration.") + assert jlr.log.contains("Using SAP Cloud Platform Console Client '/param/neo/tools/neo.sh'.") + } + + + @Test + void neoHomeFromEnvironmentTest() { + + jsr.step.call(script: nullScript, + archivePath: archiveName + ) + + assert jscr.shell.find { c -> c.contains("\"/opt/neo/tools/neo.sh\" deploy-mta")} + assert jlr.log.contains("SAP Cloud Platform Console Client home '/opt/neo' retrieved from environment.") + assert jlr.log.contains("Using SAP Cloud Platform Console Client '/opt/neo/tools/neo.sh'.") + } + + + @Test + void neoHomeFromCustomStepConfigurationTest() { + + helper.registerAllowedMethod('sh', [Map], { Map m -> getVersionWithPath(m) }) + + nullScript.commonPipelineEnvironment.configuration = [steps:[neoDeploy: [host: 'test.deploy.host.com', account: 'trialuser123', neoHome: '/config/neo']]] + + jsr.step.call(script: nullScript, + archivePath: archiveName + ) + + assert jscr.shell.find { c -> c = "\"/config/neo/tools/neo.sh\" deploy-mta"} + assert jlr.log.contains("SAP Cloud Platform Console Client home '/config/neo' retrieved from configuration.") + assert jlr.log.contains("Using SAP Cloud Platform Console Client '/config/neo/tools/neo.sh'.") + } + + @Test void archiveNotProvidedTest() { @@ -221,7 +283,7 @@ class NeoDeployTest extends BasePiperTest { jsr.step.call(script: nullScript, archivePath: archiveName, deployMode: 'mta') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy-mta") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy-mta") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasOption('synchronous', '') @@ -244,7 +306,7 @@ class NeoDeployTest extends BasePiperTest { archivePath: warArchiveName) Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasSingleQuotedOption('application', 'testApp') @@ -270,7 +332,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh rolling-update") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" rolling-update") .hasSingleQuotedOption('host', 'test\\.deploy\\.host\\.com') .hasSingleQuotedOption('account', 'trialuser123') .hasSingleQuotedOption('application', 'testApp') @@ -296,7 +358,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh deploy") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" deploy") .hasArgument("config.properties") .hasSingleQuotedOption('user', 'defaultUser') .hasSingleQuotedOption('password', '\\*\\*\\*\\*\\*\\*\\*\\*') @@ -317,7 +379,7 @@ class NeoDeployTest extends BasePiperTest { vmSize: 'lite') Assert.assertThat(jscr.shell, - new CommandLineMatcher().hasProlog("#!/bin/bash neo.sh rolling-update") + new CommandLineMatcher().hasProlog("#!/bin/bash \"/opt/neo/tools/neo.sh\" rolling-update") .hasArgument('config.properties') .hasSingleQuotedOption('user', 'defaultUser') .hasSingleQuotedOption('password', '\\*\\*\\*\\*\\*\\*\\*\\*') @@ -474,8 +536,12 @@ class NeoDeployTest extends BasePiperTest { if(m.script.contains('JAVA_HOME')) { return '/opt/java' + } else if(m.script.contains('NEO_HOME')) { + return '/opt/neo' } else if (m.script.contains('which java')) { return 0 + } else if (m.script.contains('which neo')) { + return 0 } else { return 0 } @@ -485,8 +551,12 @@ class NeoDeployTest extends BasePiperTest { if(m.script.contains('JAVA_HOME')) { return '' + } else if(m.script.contains('NEO_HOME')) { + return '' } else if (m.script.contains('which java')) { return 0 + } else if (m.script.contains('which neo')) { + return 0 } else { return 0 } diff --git a/vars/neoDeploy.groovy b/vars/neoDeploy.groovy index 4aac3dab6..8cfec18df 100644 --- a/vars/neoDeploy.groovy +++ b/vars/neoDeploy.groovy @@ -22,6 +22,7 @@ def call(parameters = [:]) { 'dockerOptions', 'host', 'neoCredentialsId', + 'neoHome', 'propertiesFile', 'runtime', 'runtimeVersion', @@ -35,7 +36,8 @@ def call(parameters = [:]) { 'dockerImage', 'dockerOptions', 'host', - 'neoCredentialsId' + 'neoCredentialsId', + 'neoHome' ] handlePipelineStepErrors (stepName: stepName, stepParameters: parameters) { @@ -146,22 +148,26 @@ def call(parameters = [:]) { deployAccount = utils.getMandatoryParameter(configuration, 'account') } - def neoCmdArgs = """${warAction} \ + def neo = new ToolDescriptor('SAP Cloud Platform Console Client', 'NEO_HOME', 'neoHome', '/tools/', 'neo.sh', null, 'version') + def neoExecutable = neo.getToolExecutable(this, configuration) + def neoDeployScript = """#!/bin/bash + "${neoExecutable}" ${warAction} \ --source "${archivePath}" \ - """ + """ + if (deployMode in ['mta', 'warParams']) { - neoCmdArgs += + neoDeployScript += """--host '${deployHost}' \ --account '${deployAccount}' \ """ } if (deployMode == 'mta') { - neoCmdArgs += "--synchronous" + neoDeployScript += "--synchronous" } if (deployMode == 'warParams') { - neoCmdArgs += + neoDeployScript += """--application '${applicationName}' \ --runtime '${runtime}' \ --runtime-version '${runtimeVersion}' \ @@ -169,7 +175,7 @@ def call(parameters = [:]) { } if (deployMode == 'warPropertiesFile') { - neoCmdArgs += + neoDeployScript += """${propertiesFile}""" } @@ -186,11 +192,12 @@ def call(parameters = [:]) { dockerEnvVars: configuration.get('dockerEnvVars'), dockerOptions: configuration.get('dockerOptions')) { + neo.verify(this, configuration) + def java = new ToolDescriptor('Java', 'JAVA_HOME', '', '/bin/', 'java', '1.8.0', '-version 2>&1') java.verify(this, configuration) - sh """#!/bin/bash - neo.sh ${neoCmdArgs} \ + sh """${neoDeployScript} \ ${credentials} """ } From d844b23b9027857ef18e789f8d1e8e1e83a0b631 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 30 Jul 2018 09:28:24 +0200 Subject: [PATCH 10/24] add cloudFoundryDeploy step (#173) * add cloudFoundryDeploy step * added cf-cli docker image * support existing parameters * support check script with flexible STATUS_CODE * add mandatory parameters * fix compatibility handling * address PR feedback * fix access to smokeTest script * make credentialsId mandatory to avoid NPE --- resources/blueGreenCheck.sh | 3 + resources/default_pipeline_environment.yml | 17 ++ src/com/sap/piper/ConfigurationHelper.groovy | 40 ++- test/groovy/CloudFoundryDeployTest.groovy | 271 ++++++++++++++++++ .../sap/piper/ConfigurationHelperTest.groovy | 64 ++++- vars/cloudFoundryDeploy.groovy | 167 +++++++++++ 6 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 resources/blueGreenCheck.sh create mode 100644 test/groovy/CloudFoundryDeployTest.groovy create mode 100644 vars/cloudFoundryDeploy.groovy diff --git a/resources/blueGreenCheck.sh b/resources/blueGreenCheck.sh new file mode 100644 index 000000000..0d856cbb3 --- /dev/null +++ b/resources/blueGreenCheck.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# this is simply testing if the application root returns HTTP STATUS_CODE +curl -so /dev/null -w '%{response_code}' https://$1 | grep $STATUS_CODE diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index dc2eee116..b9fad7110 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -84,6 +84,23 @@ steps: fail: high: '0' archive: false + cloudFoundryDeploy: + cloudFoundry: + apiEndpoint: 'https://api.cf.eu10.hana.ondemand.com' + deployTool: 'cf_native' + deployType: 'standard' + mtaDeployParameters: '-f' + mtaExtensionDescriptor: '' + mtaPath: '' + smokeTestScript: 'blueGreenCheck.sh' + smokeTestStatusCode: 200 + stashContent: [] + cf_native: + dockerImage: 's4sdk/docker-cf-cli' + dockerWorkspace: '/home/piper' + mtaDeployPlugin: + dockerImage: 's4sdk/docker-cf-cli' + dockerWorkspace: '/home/piper' influxWriteData: influxServer: 'jenkins' mavenExecute: diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index e07752cd1..70400a2a1 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -26,27 +26,53 @@ class ConfigurationHelper implements Serializable { return this } - ConfigurationHelper mixinGeneralConfig(commonPipelineEnvironment, Set filter = null){ + ConfigurationHelper mixinGeneralConfig(commonPipelineEnvironment, Set filter = null, Script step = null, Map compatibleParameters = [:]){ Map stepConfiguration = ConfigurationLoader.generalConfiguration([commonPipelineEnvironment: commonPipelineEnvironment]) - return mixin(stepConfiguration, filter) + return mixin(stepConfiguration, filter, step, compatibleParameters) } - ConfigurationHelper mixinStageConfig(commonPipelineEnvironment, stageName, Set filter = null){ + ConfigurationHelper mixinStageConfig(commonPipelineEnvironment, stageName, Set filter = null, Script step = null, Map compatibleParameters = [:]){ Map stageConfiguration = ConfigurationLoader.stageConfiguration([commonPipelineEnvironment: commonPipelineEnvironment], stageName) - return mixin(stageConfiguration, filter) + return mixin(stageConfiguration, filter, step, compatibleParameters) } - ConfigurationHelper mixinStepConfig(commonPipelineEnvironment, Set filter = null){ + ConfigurationHelper mixinStepConfig(commonPipelineEnvironment, Set filter = null, Script step = null, Map compatibleParameters = [:]){ if(!name) throw new IllegalArgumentException('Step has no public name property!') Map stepConfiguration = ConfigurationLoader.stepConfiguration([commonPipelineEnvironment: commonPipelineEnvironment], name) - return mixin(stepConfiguration, filter) + return mixin(stepConfiguration, filter, step, compatibleParameters) } - ConfigurationHelper mixin(Map parameters, Set filter = null){ + ConfigurationHelper mixin(Map parameters, Set filter = null, Script step = null, Map compatibleParameters = [:]){ + if (parameters.size() > 0 && compatibleParameters.size() > 0) { + parameters = ConfigurationMerger.merge(handleCompatibility(step, compatibleParameters, parameters), null, parameters) + } config = ConfigurationMerger.merge(parameters, filter, config) return this } + private Map handleCompatibility(Script step, Map compatibleParameters, String paramStructure = '', Map configMap ) { + Map newConfig = [:] + compatibleParameters.each {entry -> + if (entry.getValue() instanceof Map) { + paramStructure = (paramStructure ? paramStructure + '.' : '') + entry.getKey() + newConfig[entry.getKey()] = handleCompatibility(step, entry.getValue(), paramStructure, configMap) + } else { + def configSubMap = configMap + for(String key in paramStructure.tokenize('.')){ + configSubMap = configSubMap?.get(key) + } + if (configSubMap == null || (configSubMap != null && configSubMap[entry.getKey()] == null)) { + newConfig[entry.getKey()] = configMap[entry.getValue()] + def paramName = (paramStructure ? paramStructure + '.' : '') + entry.getKey() + if (step && configMap[entry.getValue()] != null) { + step.echo ("[INFO] The parameter '${entry.getValue()}' is COMPATIBLE to the parameter '${paramName}'") + } + } + } + } + return newConfig + } + Map dependingOn(dependentKey){ return [ mixin: {key -> diff --git a/test/groovy/CloudFoundryDeployTest.groovy b/test/groovy/CloudFoundryDeployTest.groovy new file mode 100644 index 000000000..cd88b40f0 --- /dev/null +++ b/test/groovy/CloudFoundryDeployTest.groovy @@ -0,0 +1,271 @@ +#!groovy +import groovy.json.JsonSlurperClassic +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.rules.RuleChain +import org.yaml.snakeyaml.Yaml +import util.BasePiperTest +import util.JenkinsEnvironmentRule +import util.JenkinsDockerExecuteRule +import util.JenkinsLoggingRule +import util.JenkinsShellCallRule +import util.JenkinsStepRule +import util.JenkinsWriteFileRule +import util.Rules + +import static org.junit.Assert.assertTrue +import static org.junit.Assert.assertEquals + +class CloudFoundryDeployTest extends BasePiperTest { + + private ExpectedException thrown = ExpectedException.none() + private JenkinsLoggingRule jlr = new JenkinsLoggingRule(this) + private JenkinsShellCallRule jscr = new JenkinsShellCallRule(this) + private JenkinsWriteFileRule jwfr = new JenkinsWriteFileRule(this) + private JenkinsDockerExecuteRule jedr = new JenkinsDockerExecuteRule(this) + private JenkinsStepRule jsr = new JenkinsStepRule(this) + private JenkinsEnvironmentRule jer = new JenkinsEnvironmentRule(this) + + @Rule + public RuleChain rules = Rules + .getCommonRules(this) + .around(thrown) + .around(jlr) + .around(jscr) + .around(jwfr) + .around(jedr) + .around(jer) + .around(jsr) // needs to be activated after jedr, otherwise executeDocker is not mocked + + @Before + void init() throws Throwable { + helper.registerAllowedMethod('usernamePassword', [Map], { m -> return m }) + helper.registerAllowedMethod('withCredentials', [List, Closure], { l, c -> + if(l[0].credentialsId == 'test_cfCredentialsId') { + binding.setProperty('username', 'test_cf') + binding.setProperty('password', '********') + } else if(l[0].credentialsId == 'test_camCredentialsId') { + binding.setProperty('username', 'test_cam') + binding.setProperty('password', '********') + } + try { + c() + } finally { + binding.setProperty('username', null) + binding.setProperty('password', null) + } + }) + } + + + @Test + void testNoTool() throws Exception { + + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + camSystemRole: 'testRole', + cfCredentialsId: 'myCreds' + ], + stages: [ + acceptance: [ + cfOrg: 'testOrg', + cfSpace: 'testSpace', + deployUser: 'testUser', + ] + ], + steps: [ + cloudFoundryDeploy: [] + ] + ] + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: '', + stageName: 'acceptance', + ]) + + assertTrue(jlr.log.contains('[cloudFoundryDeploy] General parameters: deployTool=, deployType=standard, cfApiEndpoint=https://api.cf.eu10.hana.ondemand.com, cfOrg=testOrg, cfSpace=testSpace, cfCredentialsId=myCreds, deployUser=testUser')) + } + + @Test + void testNotAvailableTool() throws Exception { + + nullScript.commonPipelineEnvironment.configuration = [ + general: [ + cfCredentialsId: 'myCreds' + ], + stages: [ + acceptance: [ + cfOrg: 'testOrg', + cfSpace: 'testSpace', + deployUser: 'testUser', + ] + ], + steps: [ + cloudFoundryDeploy: [] + ] + ] + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'notAvailable', + stageName: 'acceptance' + ]) + + assertTrue(jlr.log.contains('[cloudFoundryDeploy] General parameters: deployTool=notAvailable, deployType=standard, cfApiEndpoint=https://api.cf.eu10.hana.ondemand.com, cfOrg=testOrg, cfSpace=testSpace, cfCredentialsId=myCreds, deployUser=testUser')) + } + + @Test + void testCfNativeWithAppName() { + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml' + ]) + + assertEquals('s4sdk/docker-cf-cli', jedr.dockerParams.dockerImage) + assertEquals('/home/piper', jedr.dockerParams.dockerWorkspace) + assertEquals('200', jedr.dockerParams.dockerEnvVars.STATUS_CODE.toString()) + + + assertTrue(jscr.shell[1].contains('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')) + assertTrue(jscr.shell[1].contains('cf push "testAppName" -f "test.yml"')) + } + + @Test + void testCfNativeWithAppNameCustomApi() { + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'cf_native', + cfApiEndpoint: 'https://customApi', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfAppName: 'testAppName', + cfManifest: 'test.yml' + ]) + + assertTrue(jscr.shell[1].contains('cf login -u "test_cf" -p \'********\' -a https://customApi -o "testOrg" -s "testSpace"')) + } + + @Test + void testCfNativeWithAppNameCompatible() { + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'cf_native', + cloudFoundry: [ + org: 'testOrg', + space: 'testSpace', + credentialsId: 'test_cfCredentialsId', + appName: 'testAppName', + manifest: 'test.yml' + ] + ]) + + assertEquals('s4sdk/docker-cf-cli', jedr.dockerParams.dockerImage) + assertEquals('/home/piper', jedr.dockerParams.dockerWorkspace) + assertEquals('200', jedr.dockerParams.dockerEnvVars.STATUS_CODE.toString()) + + + assertTrue(jscr.shell[1].contains('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')) + assertTrue(jscr.shell[1].contains('cf push "testAppName" -f "test.yml"')) + } + + @Test + void testCfNativeAppNameFromManifest() { + + helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) + + helper.registerAllowedMethod("readYaml", [Map], { Map m -> + if(m.text) { + return new Yaml().load(m.text) + } else if(m.file == 'test.yml') { + return [applications: [[name: 'manifestAppName']]] + } else if(m.file) { + return new Yaml().load((m.file as File).text) + } else { + throw new IllegalArgumentException("Key 'text' is missing in map ${m}.") + } + }) + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfManifest: 'test.yml' + ]) + + assertTrue(jscr.shell[1].contains('cf login -u "test_cf" -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')) + assertTrue(jscr.shell[1].contains('cf push -f "test.yml"')) + + } + + @Test + void testCfNativeWithoutAppName() { + + helper.registerAllowedMethod('fileExists', [String.class], { s -> return true }) + + helper.registerAllowedMethod("readYaml", [Map], { Map m -> + if(m.text) { + return new Yaml().load(m.text) + } else if(m.file == 'test.yml') { + return [applications: [[]]] + } else if(m.file) { + return new Yaml().load((m.file as File).text) + } else { + throw new IllegalArgumentException("Key 'text' is missing in map ${m}.") + } + }) + + thrown.expect(hudson.AbortException) + thrown.expectMessage('[cloudFoundryDeploy] ERROR: No appName available in manifest test.yml.') + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + deployTool: 'cf_native', + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + cfManifest: 'test.yml' + ]) + } + + @Test + void testMta() { + + jsr.step.cloudFoundryDeploy([ + script: nullScript, + juStabUtils: utils, + cfOrg: 'testOrg', + cfSpace: 'testSpace', + cfCredentialsId: 'test_cfCredentialsId', + deployTool: 'mtaDeployPlugin', + mtaPath: 'target/test.mtar' + ]) + + assertEquals('s4sdk/docker-cf-cli', jedr.dockerParams.dockerImage) + assertEquals('/home/piper', jedr.dockerParams.dockerWorkspace) + + assertTrue(jscr.shell[0].contains('cf login -u test_cf -p \'********\' -a https://api.cf.eu10.hana.ondemand.com -o "testOrg" -s "testSpace"')) + assertTrue(jscr.shell[0].contains("cf deploy target/test.mtar -f".toString())) + } + +} diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 506c2f23e..27b66b4db 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -158,6 +158,69 @@ class ConfigurationHelperTest { Assert.assertThat(config, hasEntry('executeDocker3', false)) } + @Test + void testHandleCompatibility() { + def configuration = new ConfigurationHelper() + .mixin([old1: 'oldValue1', old2: 'oldValue2', test: 'testValue'], null, null, [newStructure: [new1: 'old1', new2: 'old2']]) + .use() + + Assert.assertThat(configuration.size(), is(4)) + Assert.assertThat(configuration.newStructure.new1, is('oldValue1')) + Assert.assertThat(configuration.newStructure.new2, is('oldValue2')) + } + + @Test + void testHandleCompatibilityFlat() { + def configuration = new ConfigurationHelper() + .mixin([old1: 'oldValue1', old2: 'oldValue2', test: 'testValue'], null, null, [new1: 'old1', new2: 'old2']) + .use() + + Assert.assertThat(configuration.size(), is(5)) + Assert.assertThat(configuration.new1, is('oldValue1')) + Assert.assertThat(configuration.new2, is('oldValue2')) + } + + @Test + void testHandleCompatibilityDeep() { + def configuration = new ConfigurationHelper() + .mixin([old1: 'oldValue1', old2: 'oldValue2', test: 'testValue'], null, null, [deep:[deeper:[newStructure: [new1: 'old1', new2: 'old2']]]]) + .use() + + Assert.assertThat(configuration.size(), is(4)) + Assert.assertThat(configuration.deep.deeper.newStructure.new1, is('oldValue1')) + Assert.assertThat(configuration.deep.deeper.newStructure.new2, is('oldValue2')) + } + + @Test + void testHandleCompatibilityNewAvailable() { + def configuration = new ConfigurationHelper([old1: 'oldValue1', newStructure: [new1: 'newValue1'], test: 'testValue']) + .mixin([old1: 'oldValue1', newStructure: [new1: 'newValue1'], test: 'testValue'], null, null, [newStructure: [new1: 'old1', new2: 'old2']]) + .use() + + Assert.assertThat(configuration.size(), is(3)) + Assert.assertThat(configuration.newStructure.new1, is('newValue1')) + } + + @Test + void testHandleCompatibilityOldNotSet() { + def configuration = new ConfigurationHelper([old1: null, test: 'testValue']) + .mixin([old1: null, test: 'testValue'], null, null, [newStructure: [new1: 'old1', new2: 'old2']]) + .use() + + Assert.assertThat(configuration.size(), is(2)) + Assert.assertThat(configuration.newStructure.new1, is(null)) + } + + @Test + void testHandleCompatibilityNoneAvailable() { + def configuration = new ConfigurationHelper([old1: null, test: 'testValue']) + .mixin([test: 'testValue'], null, null, [newStructure: [new1: 'old1', new2: 'old2']]) + .use() + + Assert.assertThat(configuration.size(), is(2)) + Assert.assertThat(configuration.newStructure.new1, is(null)) + } + @Test public void testWithMandoryParameterReturnDefaultFailureMessage() { @@ -185,5 +248,4 @@ class ConfigurationHelperTest { public void testWithMandoryParameterDefaultCustomFailureMessageNotProvidedSucceeds() { new ConfigurationHelper([myKey: 'myValue']).withMandatoryProperty('myKey') } - } diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy new file mode 100644 index 000000000..de62e0852 --- /dev/null +++ b/vars/cloudFoundryDeploy.groovy @@ -0,0 +1,167 @@ +import com.sap.piper.Utils +import com.sap.piper.ConfigurationHelper + +import groovy.transform.Field + +@Field String STEP_NAME = 'cloudFoundryDeploy' +@Field Set STEP_CONFIG_KEYS = [ + 'cloudFoundry', + 'deployUser', + 'deployTool', + 'deployType', + 'dockerImage', + 'dockerWorkspace', + 'mtaDeployParameters', + 'mtaExtensionDescriptor', + 'mtaPath', + 'smokeTestScript', + 'smokeTestStatusCode', + 'stashContent'] +@Field Map CONFIG_KEY_COMPATIBILITY = [cloudFoundry: [apiEndpoint: 'cfApiEndpoint', appName:'cfAppName', credentialsId: 'cfCredentialsId', manifest: 'cfManifest', org: 'cfOrg', space: 'cfSpace']] +@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS + +def call(Map parameters = [:]) { + + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { + + def utils = parameters.juStabUtils + if (utils == null) { + utils = new Utils() + } + + def script = parameters.script + if (script == null) + script = [commonPipelineEnvironment: commonPipelineEnvironment] + + Map config = ConfigurationHelper + .loadStepDefaults(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .mixin(parameters, PARAMETER_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .dependingOn('deployTool').mixin('dockerImage') + .dependingOn('deployTool').mixin('dockerWorkspace') + .withMandatoryProperty('cloudFoundry/org') + .withMandatoryProperty('cloudFoundry/space') + .withMandatoryProperty('cloudFoundry/credentialsId') + .use() + + echo "[${STEP_NAME}] General parameters: deployTool=${config.deployTool}, deployType=${config.deployType}, cfApiEndpoint=${config.cloudFoundry.apiEndpoint}, cfOrg=${config.cloudFoundry.org}, cfSpace=${config.cloudFoundry.space}, cfCredentialsId=${config.cloudFoundry.credentialsId}, deployUser=${config.deployUser}" + + utils.unstash 'deployDescriptor' + + if (config.deployTool == 'mtaDeployPlugin') { + // set default mtar path + config = new ConfigurationHelper(config) + .addIfEmpty('mtaPath', config.mtaPath?:findMtar()) + .use() + + dockerExecute(dockerImage: config.dockerImage, dockerWorkspace: config.dockerWorkspace, stashContent: config.stashContent) { + deployMta(config) + } + return + } + + if (config.deployTool == 'cf_native') { + config.smokeTest = '' + + if (config.smokeTestScript == 'blueGreenCheck.sh') { + writeFile file: config.smokeTestScript, text: libraryResource(config.smokeTestScript) + } else { + utils.unstash 'pipelineConfigAndTests' + } + 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 ( + 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) + } + + return + } + } +} + +def findMtar(){ + def mtarPath = '' + def mtarFiles = findFiles(glob: '**/target/*.mtar') + + if(mtarFiles.length > 1){ + error 'Found multiple *.mtar files, please specify file via mtaPath parameter! ${mtarFiles}' + } + if(mtarFiles.length == 1){ + return mtarFiles[0].path + } + error 'No *.mtar file found!' +} + +def deployCfNative (config) { + withCredentials([usernamePassword( + credentialsId: config.cloudFoundry.credentialsId, + passwordVariable: 'password', + usernameVariable: 'username' + )]) { + def deployCommand = 'push' + if (config.deployType == 'blue-green') { + deployCommand = 'blue-green-deploy' + } else { + config.smokeTest = '' + } + + // check if appName is available + if (config.cloudFoundry.appName == null || config.cloudFoundry.appName == '') { + 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." + } + } + + sh """#!/bin/bash + set +x + export HOME=${config.dockerWorkspace} + cf login -u \"${username}\" -p '${password}' -a ${config.cloudFoundry.apiEndpoint} -o \"${config.cloudFoundry.org}\" -s \"${config.cloudFoundry.space}\" + cf plugins + cf ${deployCommand} ${config.cloudFoundry.appName?"\"${config.cloudFoundry.appName}\"":''} -f \"${config.cloudFoundry.manifest}\" ${config.smokeTest}""" + def retVal = sh script: "cf app \"${config.cloudFoundry.appName}-old\"", returnStatus: true + if (retVal == 0) { + sh "cf delete \"${config.cloudFoundry.appName}-old\" -r -f" + } + sh "cf logout" + } +} + +def deployMta (config) { + if (config.mtaExtensionDescriptor == null) config.mtaExtensionDescriptor = '' + if (!config.mtaExtensionDescriptor.isEmpty() && !config.mtaExtensionDescriptor.startsWith('-e ')) config.mtaExtensionDescriptor = "-e ${config.mtaExtensionDescriptor}" + + def deployCommand = 'deploy' + if (config.deployType == 'blue-green') + deployCommand = 'bg-deploy' + + withCredentials([usernamePassword( + credentialsId: config.cloudFoundry.credentialsId, + passwordVariable: 'password', + usernameVariable: 'username' + )]) { + echo "[${STEP_NAME}] Deploying MTA (${config.mtaPath}) with following parameters: ${config.mtaExtensionDescriptor} ${config.mtaDeployParameters}" + sh """#!/bin/bash + export HOME=${config.dockerWorkspace} + set +x + 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}""" + sh "cf logout" + } +} From 745b6d471d773be5efa27614414c9bc0db5c21e6 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 30 Jul 2018 12:06:35 +0200 Subject: [PATCH 11/24] fix issue with resource naming conflict (#234) --- resources/{blueGreenCheck.sh => blueGreenCheckScript.sh} | 0 resources/default_pipeline_environment.yml | 2 +- vars/cloudFoundryDeploy.groovy | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename resources/{blueGreenCheck.sh => blueGreenCheckScript.sh} (100%) diff --git a/resources/blueGreenCheck.sh b/resources/blueGreenCheckScript.sh similarity index 100% rename from resources/blueGreenCheck.sh rename to resources/blueGreenCheckScript.sh diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index b9fad7110..f6ac17632 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -92,7 +92,7 @@ steps: mtaDeployParameters: '-f' mtaExtensionDescriptor: '' mtaPath: '' - smokeTestScript: 'blueGreenCheck.sh' + smokeTestScript: 'blueGreenCheckScript.sh' smokeTestStatusCode: 200 stashContent: [] cf_native: diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index de62e0852..8741061ed 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -65,7 +65,7 @@ def call(Map parameters = [:]) { if (config.deployTool == 'cf_native') { config.smokeTest = '' - if (config.smokeTestScript == 'blueGreenCheck.sh') { + if (config.smokeTestScript == 'blueGreenCheckScript.sh') { writeFile file: config.smokeTestScript, text: libraryResource(config.smokeTestScript) } else { utils.unstash 'pipelineConfigAndTests' From fb431ff27eaaa2f3d7af19e93a217bf233af5a82 Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 31 Jul 2018 10:25:32 +0200 Subject: [PATCH 12/24] [fix] provided nested config for retrieving transport request id in upload file --- vars/transportRequestUploadFile.groovy | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vars/transportRequestUploadFile.groovy b/vars/transportRequestUploadFile.groovy index 4095ae55e..af6908fb1 100644 --- a/vars/transportRequestUploadFile.groovy +++ b/vars/transportRequestUploadFile.groovy @@ -83,15 +83,15 @@ def call(parameters = [:]) { } else { - echo "[INFO] Retrieving transport request id from commit history [from: ${configuration.gitFrom}, to: ${configuration.gitTo}]." + - " Searching for pattern '${configuration.gitTransportRequestLabel}'. Searching with format '${configuration.gitFormat}'." + echo "[INFO] Retrieving transport request id from commit history [from: ${configuration.changeManagement.git.from}, to: ${configuration.changeManagement.git.to}]." + + " Searching for pattern '${configuration.changeManagement.transportRequestLabel}'. Searching with format '${configuration.changeManagement.git.format}'." try { transportRequestId = cm.getTransportRequestId( - configuration.gitFrom, - configuration.gitTo, - configuration.gitTransportRequestLabel, - configuration.gitFormat + configuration.changeManagement.git.from, + configuration.changeManagement.git.to, + configuration.changeManagement.transportRequestLabel, + configuration.changeManagement.git.format ) echo "[INFO] Transport request id '${transportRequestId}' retrieved from commit history" From c5d3ed7ab8ae03cbba893118645291f30bc5187e Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Tue, 31 Jul 2018 15:42:27 +0200 Subject: [PATCH 13/24] add condition to withMandatoryProperty --- src/com/sap/piper/ConfigurationHelper.groovy | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index 70400a2a1..d69ed88f8 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -162,8 +162,13 @@ class ConfigurationHelper implements Serializable { return paramValue } - def withMandatoryProperty(key, errorMessage = null){ - getMandatoryProperty(key, null, errorMessage) + def withMandatoryProperty(key, errorMessage = null, condition = null){ + if(condition){ + if(condition(this.config)) + getMandatoryProperty(key, null, errorMessage) + }else{ + getMandatoryProperty(key, null, errorMessage) + } return this } } From 0bac7ac27e880590572cb651cbbf13c582a60e0c Mon Sep 17 00:00:00 2001 From: Marcus Holl Date: Tue, 31 Jul 2018 15:43:25 +0200 Subject: [PATCH 14/24] Take mtarFilePath in commonPipelineEnviroment into account This allows the steps to interact without using return values (which is not possible in declarative pipelines). On the other hand we have depencencies between the steps based on the status of the commonPipelineEnvironment. --- .../TransportRequestUploadFileTest.groovy | 59 +++++++++++++++++++ vars/transportRequestUploadFile.groovy | 1 + 2 files changed, 60 insertions(+) diff --git a/test/groovy/TransportRequestUploadFileTest.groovy b/test/groovy/TransportRequestUploadFileTest.groovy index d2991fe33..4b3c9306c 100644 --- a/test/groovy/TransportRequestUploadFileTest.groovy +++ b/test/groovy/TransportRequestUploadFileTest.groovy @@ -181,6 +181,65 @@ public class TransportRequestUploadFileTest extends BasePiperTest { ] } + @Test + public void uploadFileToTransportRequestFilePathFromParameters() { + + // this one is not used when file path is provided via signature + nullScript.commonPipelineEnvironment.setMtarFilePath('/path2') + + ChangeManagement cm = new ChangeManagement(nullScript) { + void uploadFileToTransportRequest(String changeId, + String transportRequestId, + String applicationId, + String filePath, + String endpoint, + String username, + String password, + String cmclientOpts) { + + cmUtilReceivedParams.filePath = filePath + } + } + + jsr.step.call(script: nullScript, + changeDocumentId: '001', + transportRequestId: '002', + applicationId: 'app', + filePath: '/path', + cmUtils: cm) + + assert cmUtilReceivedParams.filePath == '/path' + } + + @Test + public void uploadFileToTransportRequestFilePathFromCommonPipelineEnvironment() { + + // this one is used since there is nothing in the signature + nullScript.commonPipelineEnvironment.setMtarFilePath('/path2') + + ChangeManagement cm = new ChangeManagement(nullScript) { + void uploadFileToTransportRequest(String changeId, + String transportRequestId, + String applicationId, + String filePath, + String endpoint, + String username, + String password, + String cmclientOpts) { + + cmUtilReceivedParams.filePath = filePath + } + } + + jsr.step.call(script: nullScript, + changeDocumentId: '001', + transportRequestId: '002', + applicationId: 'app', + cmUtils: cm) + + assert cmUtilReceivedParams.filePath == '/path2' + } + @Test public void uploadFileToTransportRequestUploadFailureTest() { diff --git a/vars/transportRequestUploadFile.groovy b/vars/transportRequestUploadFile.groovy index 4095ae55e..d5f655059 100644 --- a/vars/transportRequestUploadFile.groovy +++ b/vars/transportRequestUploadFile.groovy @@ -37,6 +37,7 @@ def call(parameters = [:]) { .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, stepConfigurationKeys) .mixinStepConfig(script.commonPipelineEnvironment, stepConfigurationKeys) .mixin(parameters, parameterKeys) + .addIfEmpty('filePath', script.commonPipelineEnvironment.getMtarFilePath()) .withMandatoryProperty('applicationId') .withMandatoryProperty('changeManagement/changeDocumentLabel') .withMandatoryProperty('changeManagement/clientOpts') From 8c7dc44d1d9ac2412ad44cbc15dabe0a8e814885 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 1 Aug 2018 08:30:10 +0200 Subject: [PATCH 15/24] add tests --- .../sap/piper/ConfigurationHelperTest.groovy | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 27b66b4db..624784731 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -248,4 +248,30 @@ class ConfigurationHelperTest { public void testWithMandoryParameterDefaultCustomFailureMessageNotProvidedSucceeds() { new ConfigurationHelper([myKey: 'myValue']).withMandatoryProperty('myKey') } + + @Test + public void testWithMandoryWithFalseCondition() { + thrown.none() + + new ConfigurationHelper([enforce: false]).withMandatoryProperty('missingKey', null, {c -> return c.enforce}) + } + + @Test + public void testWithMandoryWithTrueConditionMissingValue() { + thrown.expect(IllegalArgumentException) + thrown.expectMessage('ERROR - NO VALUE AVAILABLE FOR missingKey') + + new ConfigurationHelper([enforce: true]).withMandatoryProperty('missingKey', null, {c -> return c.execute}) + } + + @Test + public void testWithMandoryWithTrueConditionExistingValue() { + thrown.none() + + def config = new ConfigurationHelper([existingKey: 'anyValue', enforce: true]) + .withMandatoryProperty('existingKey', null, {c -> return c.execute}) + .use() + + Assert.assertThat(config.size(), is(2)) + } } From 0db93df011572e103afdd69db878dfd782aa4dc3 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 1 Aug 2018 08:41:49 +0200 Subject: [PATCH 16/24] fix closure --- 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 624784731..6c6f6ca08 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -253,7 +253,7 @@ class ConfigurationHelperTest { public void testWithMandoryWithFalseCondition() { thrown.none() - new ConfigurationHelper([enforce: false]).withMandatoryProperty('missingKey', null, {c -> return c.enforce}) + new ConfigurationHelper([enforce: false]).withMandatoryProperty('missingKey', null, {c -> return c.get('execute')}) } @Test @@ -261,7 +261,7 @@ class ConfigurationHelperTest { thrown.expect(IllegalArgumentException) thrown.expectMessage('ERROR - NO VALUE AVAILABLE FOR missingKey') - new ConfigurationHelper([enforce: true]).withMandatoryProperty('missingKey', null, {c -> return c.execute}) + new ConfigurationHelper([enforce: true]).withMandatoryProperty('missingKey', null, {c -> return c.get('execute')}) } @Test @@ -269,7 +269,7 @@ class ConfigurationHelperTest { thrown.none() def config = new ConfigurationHelper([existingKey: 'anyValue', enforce: true]) - .withMandatoryProperty('existingKey', null, {c -> return c.execute}) + .withMandatoryProperty('existingKey', null, {c -> return c.get('execute')}) .use() Assert.assertThat(config.size(), is(2)) From 30f0c5a6cc9c71e16da1bada251319c15004a95d Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 1 Aug 2018 08:50:21 +0200 Subject: [PATCH 17/24] correct test cases --- .../sap/piper/ConfigurationHelperTest.groovy | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index 6c6f6ca08..fe3942ed2 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -251,9 +251,8 @@ class ConfigurationHelperTest { @Test public void testWithMandoryWithFalseCondition() { - thrown.none() - - new ConfigurationHelper([enforce: false]).withMandatoryProperty('missingKey', null, {c -> return c.get('execute')}) + new ConfigurationHelper([verify: false]) + .withMandatoryProperty('missingKey', null, { c -> return c.get('verify') }) } @Test @@ -261,17 +260,13 @@ class ConfigurationHelperTest { thrown.expect(IllegalArgumentException) thrown.expectMessage('ERROR - NO VALUE AVAILABLE FOR missingKey') - new ConfigurationHelper([enforce: true]).withMandatoryProperty('missingKey', null, {c -> return c.get('execute')}) + new ConfigurationHelper([verify: true]) + .withMandatoryProperty('missingKey', null, { c -> return c.get('verify') }) } @Test public void testWithMandoryWithTrueConditionExistingValue() { - thrown.none() - - def config = new ConfigurationHelper([existingKey: 'anyValue', enforce: true]) - .withMandatoryProperty('existingKey', null, {c -> return c.get('execute')}) - .use() - - Assert.assertThat(config.size(), is(2)) + new ConfigurationHelper([existingKey: 'anyValue', verify: true]) + .withMandatoryProperty('existingKey', null, { c -> return c.get('verify') }) } } From cd4a9f226e5ce18fc0b6b5e1c3daa5959fef90e9 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Mon, 6 Aug 2018 08:57:36 +0200 Subject: [PATCH 18/24] Add collection of library telemetry data (#239) * add telemetry collection * add telemetry reporting for first steps * fix documentation formatting --- documentation/docs/configuration.md | 83 +++++++++++++++++++++ documentation/docs/images/piper_config.png | Bin 0 -> 88881 bytes documentation/mkdocs.yml | 3 +- resources/default_pipeline_environment.yml | 1 + src/com/sap/piper/Utils.groovy | 62 +++++++++++++++ vars/artifactSetVersion.groovy | 44 +++++------ vars/setupCommonPipelineEnvironment.groovy | 14 ++++ 7 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 documentation/docs/configuration.md create mode 100644 documentation/docs/images/piper_config.png diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md new file mode 100644 index 000000000..962250092 --- /dev/null +++ b/documentation/docs/configuration.md @@ -0,0 +1,83 @@ +# Configuration + +Configuration is done via a yml-file, located at `.pipeline/config.yml` in the **master branch** of your source code repository. + +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). + +!!! caution "Adding custom parameters" + Please note that adding custom parameters to the configuration is at your own risk. + We may introduce new parameters at any time which may clash with your custom parameters. + +Configuration of the Piper steps as well the Piper templates can be done in a hierarchical manner. + +1. Directly passed step parameters will always take precedence over other configuration values and defaults +2. Stage configuration parameters define a Jenkins pipeline stage dependent set of parameters (e.g. deployment options for the `Acceptance` stage) +3. Step configuration defines how steps behave in general (e.g. step `cloudFoundryDeploy`) +4. General configuration parameters define parameters which are available across step boundaries +5. Default configuration comes with the Piper library and is always available + +![Piper Configuration](images/piper_config.png) + +## Collecting telemetry data + +In order to improve this Jenkins library we are collecting telemetry data. +Data is send using [`com.sap.piper.pushToSWA`](https://github.com/SAP/jenkins-library/blob/master/src/com/sap/piper/Utils.groovy) + +Following data (non-personal) is collected for example: + +* Hashed job url, e.g. `4944f745e03f5f79daf0001eec9276ce351d3035` hash calculation is done in your Jenkins server and no original values are transmitted +* Name of library step which has been executed, like e.g. `artifactSetVersion` +* Certain parameters of the executed steps, e.g. `buildTool=maven` + +**We store the telemetry data for not longer than 6 months on premises of SAP SE.** + +!!! note "Disable collection of telemetry data" + If you do not want to send telemetry data which helps this open source project to improve you can easily deactivate this. + + This is done with either of the following two ways: + + 1. General deactivation in your `.pipeline/config.yml` file by setting the configuration parameter `general -> collectTelemetryData: false` (default setting can be found in the [library defaults](https://github.com/SAP/jenkins-library/blob/master/resources/default_pipeline_environment.yml)). + + **Please note: this will only take effect in all steps if you run `setupCommonPipelineEnvironment` at the beginning of your pipeline** + + 2. Individual deactivation per step by passing the parameter `collectTelemetryData: false`, like e.g. `setVersion script:this, collectTelemetryData: false` + + +## Example configuration + +``` +general: + gitSshKeyCredentialsId: GitHub_Test_SSH + +steps: + cloudFoundryDeploy: + deployTool: 'cf_native' + cloudFoundry: + org: 'testOrg' + space: 'testSpace' + credentialsId: 'MY_CF_CREDENTIALSID_IN_JENKINS' + newmanExecute: + newmanCollection: 'myNewmanCollection.file' + newmanEnvironment: 'myNewmanEnvironment' + newmanGlobals: 'myNewmanGlobals' + ``` + +## Access to configuration from custom scripts + +Configuration is loaded into `commonPipelineEnvironment` during step [setupCommonPipelineEnvironment](steps/setupCommonPipelineEnvironment.md). + +You can access the configuration values via `commonPipelineEnvironment.configuration` which will return you the complete configuration map. + +Thus following access is for example possible (accessing `gitSshKeyCredentialsId` from `general` section): +``` +commonPipelineEnvironment.configuration.general.gitSshKeyCredentialsId +``` + +## Access to configuration in custom library steps + +Within library steps the `ConfigurationHelper` object is used. + +You can see its usage in all the Piper steps, for example [newmanExecute](https://github.com/SAP/jenkins-library/blob/master/vars/newmanExecute.groovy#L23). + + + diff --git a/documentation/docs/images/piper_config.png b/documentation/docs/images/piper_config.png new file mode 100644 index 0000000000000000000000000000000000000000..a56d88f3863d3bfd473076d34bd0eb93e5eac98f GIT binary patch literal 88881 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

suv0dsWL_*73Am_t*>P9h4q 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 iTl /dev/null 2>&1 || echo '[${parameters.get('step')}] Telemetry Report to SWA failed!'") + + } catch (MissingContextVariableException noNode) { + echo "[${parameters.get('step')}] Telemetry Report to SWA skipped, no node available!" + } catch (ignore) { + // some error occured in SWA reporting. This should not break anything though. + } +} + diff --git a/vars/artifactSetVersion.groovy b/vars/artifactSetVersion.groovy index b8d89006a..587f5fd34 100644 --- a/vars/artifactSetVersion.groovy +++ b/vars/artifactSetVersion.groovy @@ -7,6 +7,7 @@ import groovy.transform.Field import groovy.text.SimpleTemplateEngine @Field String STEP_NAME = 'artifactSetVersion' +@Field Set GENERAL_CONFIG_KEYS = ['collectTelemetryData'] @Field Set STEP_CONFIG_KEYS = [ 'artifactType', 'buildTool', @@ -28,10 +29,7 @@ def call(Map parameters = [:]) { handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { - def gitUtils = parameters.juStabGitUtils - if (gitUtils == null) { - gitUtils = new GitUtils() - } + def gitUtils = parameters.juStabGitUtils ?: new GitUtils() if (fileExists('.git')) { if (sh(returnStatus: true, script: 'git diff --quiet HEAD') != 0) @@ -43,23 +41,25 @@ def call(Map parameters = [:]) { script = this // load default & individual configuration - Map configuration = ConfigurationHelper + Map config = ConfigurationHelper .loadStepDefaults(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(gitCommitId: gitUtils.getGitCommitIdOrNull()) .mixin(parameters, PARAMETER_KEYS) + .withMandatoryProperty('buildTool') .use() - def utils = new Utils() - def buildTool = utils.getMandatoryParameter(configuration, 'buildTool') + new Utils().pushToSWA([step: STEP_NAME, stepParam1: config.buildTool], config) - if (!configuration.filePath) - configuration.filePath = configuration[buildTool].filePath //use default configuration + if (!config.filePath) + config.filePath = config[config.buildTool].filePath //use default configuration def newVersion - def artifactVersioning = ArtifactVersioning.getArtifactVersioning(buildTool, script, configuration) + def artifactVersioning = ArtifactVersioning.getArtifactVersioning(config.buildTool, script, config) - if(configuration.artifactType == 'appContainer' && configuration.dockerVersionSource == 'appVersion'){ + if(config.artifactType == 'appContainer' && config.dockerVersionSource == 'appVersion'){ if (script.commonPipelineEnvironment.getArtifactVersion()) //replace + sign if available since + is not allowed in a Docker tag newVersion = script.commonPipelineEnvironment.getArtifactVersion().replace('+', '_') @@ -68,11 +68,11 @@ def call(Map parameters = [:]) { } else { def currentVersion = artifactVersioning.getVersion() - def timestamp = configuration.timestamp ? configuration.timestamp : getTimestamp(configuration.timestampTemplate) + def timestamp = config.timestamp ? config.timestamp : getTimestamp(config.timestampTemplate) - def versioningTemplate = configuration.versioningTemplate ? configuration.versioningTemplate : configuration[configuration.buildTool].versioningTemplate + def versioningTemplate = config.versioningTemplate ? config.versioningTemplate : config[config.buildTool].versioningTemplate //defined in default configuration - def binding = [version: currentVersion, timestamp: timestamp, commitId: configuration.gitCommitId] + def binding = [version: currentVersion, timestamp: timestamp, commitId: config.gitCommitId] def templatingEngine = new SimpleTemplateEngine() def template = templatingEngine.createTemplate(versioningTemplate).make(binding) newVersion = template.toString() @@ -82,28 +82,28 @@ def call(Map parameters = [:]) { def gitCommitId - if (configuration.commitVersion) { + if (config.commitVersion) { sh 'git add .' - sshagent([configuration.gitCredentialsId]) { + sshagent([config.gitCredentialsId]) { def gitUserMailConfig = '' - if (configuration.gitUserName && configuration.gitUserEMail) - gitUserMailConfig = "-c user.email=\"${configuration.gitUserEMail}\" -c user.name=\"${configuration.gitUserName}\"" + if (config.gitUserName && config.gitUserEMail) + gitUserMailConfig = "-c user.email=\"${config.gitUserEMail}\" -c user.name=\"${config.gitUserName}\"" try { sh "git ${gitUserMailConfig} commit -m 'update version ${newVersion}'" } catch (e) { error "[${STEP_NAME}]git commit failed: ${e}" } - sh "git remote set-url origin ${configuration.gitSshUrl}" - sh "git tag ${configuration.tagPrefix}${newVersion}" - sh "git push origin ${configuration.tagPrefix}${newVersion}" + sh "git remote set-url origin ${config.gitSshUrl}" + sh "git tag ${config.tagPrefix}${newVersion}" + sh "git push origin ${config.tagPrefix}${newVersion}" gitCommitId = gitUtils.getGitCommitIdOrNull() } } - if (buildTool == 'docker' && configuration.artifactType == 'appContainer') { + if (config.buildTool == 'docker' && config.artifactType == 'appContainer') { script.commonPipelineEnvironment.setAppContainerProperty('artifactVersion', newVersion) script.commonPipelineEnvironment.setAppContainerProperty('gitCommitId', gitCommitId) } else { diff --git a/vars/setupCommonPipelineEnvironment.groovy b/vars/setupCommonPipelineEnvironment.groovy index a74486a2e..b0743f676 100644 --- a/vars/setupCommonPipelineEnvironment.groovy +++ b/vars/setupCommonPipelineEnvironment.groovy @@ -1,3 +1,10 @@ +import com.sap.piper.ConfigurationHelper +import com.sap.piper.Utils +import groovy.transform.Field + +@Field String STEP_NAME = 'setupPipelineEnvironment' +@Field Set GENERAL_CONFIG_KEYS = ['collectTelemetryData'] + def call(Map parameters = [:]) { handlePipelineStepErrors (stepName: 'setupCommonPipelineEnvironment', stepParameters: parameters) { @@ -9,6 +16,13 @@ def call(Map parameters = [:]) { String configFile = parameters.get('configFile') loadConfigurationFromFile(script, configFile) + + Map config = ConfigurationHelper + .loadStepDefaults(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) + .use() + + new Utils().pushToSWA([step: STEP_NAME], config) } } From 31c0ac95ff9a5be5e850e97c28f7d535d13501d1 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 8 Aug 2018 10:22:18 +0200 Subject: [PATCH 19/24] Use fixx mkdocs version (#240) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 6a6dcad31..ccf320a2d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: groovy sudo: false install: -- pip install --user mkdocs mkdocs-material +- pip install --user mkdocs==0.17.5 mkdocs-material==2.9.4 script: - mvn test -B - | From 240df888825605ea336d621b609057a47944c34a Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 8 Aug 2018 11:22:08 +0200 Subject: [PATCH 20/24] add stashing & private test repositories --- resources/default_pipeline_environment.yml | 2 + test/groovy/NewmanExecuteTest.groovy | 33 ++++++++++-- vars/newmanExecute.groovy | 58 ++++++++++++++-------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index a0c9101d7..ed5aa2794 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -123,6 +123,8 @@ steps: newmanEnvironment: '' newmanGlobals: '' newmanRunCommand: "run ${config.newmanCollection} --environment '${config.newmanEnvironment}' --globals '${config.newmanGlobals}' --reporters junit,html --reporter-junit-export target/newman/TEST-${collectionDisplayName}.xml --reporter-html-export target/newman/TEST-${collectionDisplayName}.html" + stashContent: + - 'tests' pipelineStashFilesAfterBuild: runOpaTests: false stashIncludes: diff --git a/test/groovy/NewmanExecuteTest.groovy b/test/groovy/NewmanExecuteTest.groovy index 8b80b55dc..ee8ff2e59 100644 --- a/test/groovy/NewmanExecuteTest.groovy +++ b/test/groovy/NewmanExecuteTest.groovy @@ -15,8 +15,10 @@ import util.JenkinsLoggingRule import util.JenkinsShellCallRule import util.JenkinsDockerExecuteRule import util.Rules +import org.junit.rules.ExpectedException class NewmanExecuteTest extends BasePiperTest { + private ExpectedException thrown = ExpectedException.none() private JenkinsStepRule jsr = new JenkinsStepRule(this) private JenkinsLoggingRule jlr = new JenkinsLoggingRule(this) private JenkinsShellCallRule jscr = new JenkinsShellCallRule(this) @@ -25,21 +27,25 @@ class NewmanExecuteTest extends BasePiperTest { @Rule public RuleChain rules = Rules .getCommonRules(this) + .around(thrown) .around(jedr) .around(jscr) .around(jlr) .around(jsr) // needs to be activated after jedr, otherwise executeDocker is not mocked - def testRepository + def gitMap @Before void init() throws Exception { - helper.registerAllowedMethod('git', [String.class], {s -> - testRepository = s + helper.registerAllowedMethod('stash', [String.class], null) + helper.registerAllowedMethod('git', [Map.class], {m -> + gitMap = m }) helper.registerAllowedMethod("findFiles", [Map.class], { map -> def files - if(map.glob == '**/*.postman_collection.json') + if(map.glob == 'notFound.json') + files = [] + else if(map.glob == '**/*.postman_collection.json') files = [ new File("testCollectionsFolder/A.postman_collection.json"), new File("testCollectionsFolder/B.postman_collection.json") @@ -54,6 +60,7 @@ class NewmanExecuteTest extends BasePiperTest { void testExecuteNewmanDefault() throws Exception { jsr.step.newmanExecute( script: nullScript, + juStabUtils: utils, newmanCollection: 'testCollection', newmanEnvironment: 'testEnvironment', newmanGlobals: 'testGlobals' @@ -61,13 +68,28 @@ class NewmanExecuteTest extends BasePiperTest { // asserts assertThat(jscr.shell, hasItem('newman run testCollection --environment \'testEnvironment\' --globals \'testGlobals\' --reporters junit,html --reporter-junit-export target/newman/TEST-testCollection.xml --reporter-html-export target/newman/TEST-testCollection.html')) assertThat(jedr.dockerParams.dockerImage, is('node:8-stretch')) + assertThat(jlr.log, containsString('[newmanExecute] Found files [testCollection]')) assertJobStatusSuccess() } + @Test + void testExecuteNewmanWithNoCollection() throws Exception { + thrown.expectMessage('[newmanExecute] No collection found with pattern \'notFound.json\'') + + jsr.step.newmanExecute( + script: nullScript, + juStabUtils: utils, + newmanCollection: 'notFound.json' + ) + // asserts + assertJobStatusFailure() + } + @Test void testExecuteNewmanFailOnError() throws Exception { jsr.step.newmanExecute( script: nullScript, + juStabUtils: utils, newmanCollection: 'testCollection', newmanEnvironment: 'testEnvironment', newmanGlobals: 'testGlobals', @@ -77,7 +99,7 @@ class NewmanExecuteTest extends BasePiperTest { ) // asserts assertThat(jedr.dockerParams.dockerImage, is('testImage')) - assertThat(testRepository, is('testRepo')) + assertThat(gitMap.url, is('testRepo')) assertThat(jscr.shell, hasItem('newman run testCollection --environment \'testEnvironment\' --globals \'testGlobals\' --reporters junit,html --reporter-junit-export target/newman/TEST-testCollection.xml --reporter-html-export target/newman/TEST-testCollection.html --suppress-exit-code')) assertJobStatusSuccess() } @@ -86,6 +108,7 @@ class NewmanExecuteTest extends BasePiperTest { void testExecuteNewmanWithFolder() throws Exception { jsr.step.newmanExecute( script: nullScript, + juStabUtils: utils, newmanRunCommand: 'run ${config.newmanCollection} --iteration-data testDataFile --reporters junit,html --reporter-junit-export target/newman/TEST-${config.newmanCollection.toString().replace(File.separatorChar,(char)\'_\').tokenize(\'.\').first()}.xml --reporter-html-export target/newman/TEST-${config.newmanCollection.toString().replace(File.separatorChar,(char)\'_\').tokenize(\'.\').first()}.html' ) // asserts diff --git a/vars/newmanExecute.groovy b/vars/newmanExecute.groovy index 876622641..68be9c2f5 100644 --- a/vars/newmanExecute.groovy +++ b/vars/newmanExecute.groovy @@ -1,3 +1,4 @@ +import com.sap.piper.Utils import com.sap.piper.ConfigurationHelper import groovy.transform.Field @@ -7,10 +8,13 @@ import groovy.text.SimpleTemplateEngine @Field Set STEP_CONFIG_KEYS = [ 'dockerImage', 'failOnError', + 'gitBranch', + 'gitSshKeyCredentialsId', 'newmanCollection', 'newmanEnvironment', 'newmanGlobals', 'newmanRunCommand', + 'stashContent', 'testRepository' ] @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS @@ -18,6 +22,7 @@ import groovy.text.SimpleTemplateEngine def call(Map parameters = [:]) { handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters) { def script = parameters?.script ?: [commonPipelineEnvironment: commonPipelineEnvironment] + def utils = parameters?.juStabUtils ?: new Utils() // load default & individual configuration Map config = ConfigurationHelper @@ -27,27 +32,40 @@ def call(Map parameters = [:]) { .mixin(parameters, PARAMETER_KEYS) .use() - List collectionList = findFiles(glob: config.newmanCollection)?.toList() + config.stashContent = utils.unstashAll(config.stashContent) - if (!config.dockerImage.isEmpty()) { - if (config.testRepository) - git config.testRepository - dockerExecute( - dockerImage: config.dockerImage - ) { - sh 'npm install newman --global --quiet' - for(String collection : collectionList){ - def collectionDisplayName = collection.toString().replace(File.separatorChar,(char)'_').tokenize('.').first() - // resolve templates - def command = SimpleTemplateEngine.newInstance() - .createTemplate(config.newmanRunCommand) - .make([ - config: config.plus([newmanCollection: collection]), - collectionDisplayName: collectionDisplayName - ]).toString() - if(!config.failOnError) command += ' --suppress-exit-code' - sh "newman ${command}" - } + if (config.testRepository) { + def gitParameters = [url: config.testRepository] + if (config.gitSshKeyCredentialsId) gitParameters.credentialsId = config.gitSshKeyCredentialsId + if (config.gitBranch) gitParameters.branch = config.gitBranch + git gitParameters + stash 'newmanContent' + config.stashContent = ['newmanContent'] + } + + List collectionList = findFiles(glob: config.newmanCollection)?.toList() + if (collectionList.isEmpty()) { + error "[${STEP_NAME}] No collection found with pattern '${config.newmanCollection}'" + } else { + echo "[${STEP_NAME}] Found files ${collectionList}" + } + + dockerExecute( + dockerImage: config.dockerImage, + stashContent: config.stashContent + ) { + sh 'npm install newman --global --quiet' + for(String collection : collectionList){ + def collectionDisplayName = collection.toString().replace(File.separatorChar,(char)'_').tokenize('.').first() + // resolve templates + def command = SimpleTemplateEngine.newInstance() + .createTemplate(config.newmanRunCommand) + .make([ + config: config.plus([newmanCollection: collection]), + collectionDisplayName: collectionDisplayName + ]).toString() + if(!config.failOnError) command += ' --suppress-exit-code' + sh "newman ${command}" } } } From d083ef6e39dfa24258467c91497864a325ed09a0 Mon Sep 17 00:00:00 2001 From: Christopher Fenner Date: Wed, 8 Aug 2018 14:23:29 +0200 Subject: [PATCH 21/24] Update newmanExecute.groovy --- vars/newmanExecute.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vars/newmanExecute.groovy b/vars/newmanExecute.groovy index 68be9c2f5..1b3611fd0 100644 --- a/vars/newmanExecute.groovy +++ b/vars/newmanExecute.groovy @@ -32,8 +32,6 @@ def call(Map parameters = [:]) { .mixin(parameters, PARAMETER_KEYS) .use() - config.stashContent = utils.unstashAll(config.stashContent) - if (config.testRepository) { def gitParameters = [url: config.testRepository] if (config.gitSshKeyCredentialsId) gitParameters.credentialsId = config.gitSshKeyCredentialsId @@ -41,6 +39,8 @@ def call(Map parameters = [:]) { git gitParameters stash 'newmanContent' config.stashContent = ['newmanContent'] + } else { + config.stashContent = utils.unstashAll(config.stashContent) } List collectionList = findFiles(glob: config.newmanCollection)?.toList() From 65b582dc9d5aea6689f7478cf476f7854ef7d753 Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Wed, 8 Aug 2018 22:21:26 +0200 Subject: [PATCH 22/24] artifactSetVersion - add new artifact types & cleanup (#242) * artifactSetVersion - add new artifact types & cleanup added: * dlang * golang * npm * pip * scala * add documentation update --- .../docs/steps/artifactSetVersion.md | 10 ++-- resources/default_pipeline_environment.yml | 16 +++++ .../versioning/ArtifactVersioning.groovy | 18 ++++-- .../versioning/DlangArtifactVersioning.groovy | 20 +++++++ .../DockerArtifactVersioning.groovy | 28 +++++++-- .../GolangArtifactVersioning.groovy | 17 ++++++ .../versioning/NpmArtifactVersioning.groovy | 20 +++++++ .../versioning/PipArtifactVersioning.groovy | 17 ++++++ .../versioning/SbtArtifactVersioning.groovy | 20 +++++++ test/groovy/ArtifactSetVersionTest.groovy | 31 +++++++--- .../DlangArtifactVersioningTest.groovy | 32 ++++++++++ .../GolangArtifactVersioningTest.groovy | 31 ++++++++++ .../NpmArtifactVersioningTest.groovy | 32 ++++++++++ .../PipArtifactVersioningTest.groovy | 31 ++++++++++ .../SbtArtifactVersioningTest.groovy | 32 ++++++++++ test/groovy/util/JenkinsWriteJsonRule.groovy | 34 +++++++++++ .../DlangArtifactVersioning/dub.json | 5 ++ .../GolangArtifactVersioning/VERSION | 1 + .../NpmArtifactVersioning/package.json | 15 +++++ .../PipArtifactVersioning/version.txt | 1 + .../SbtArtifactVersioning/sbtDescriptor.json | 4 ++ vars/artifactSetVersion.groovy | 59 +++++++++---------- 22 files changed, 420 insertions(+), 54 deletions(-) create mode 100644 src/com/sap/piper/versioning/DlangArtifactVersioning.groovy create mode 100644 src/com/sap/piper/versioning/GolangArtifactVersioning.groovy create mode 100644 src/com/sap/piper/versioning/NpmArtifactVersioning.groovy create mode 100644 src/com/sap/piper/versioning/PipArtifactVersioning.groovy create mode 100644 src/com/sap/piper/versioning/SbtArtifactVersioning.groovy create mode 100644 test/groovy/com/sap/piper/versioning/DlangArtifactVersioningTest.groovy create mode 100644 test/groovy/com/sap/piper/versioning/GolangArtifactVersioningTest.groovy create mode 100644 test/groovy/com/sap/piper/versioning/NpmArtifactVersioningTest.groovy create mode 100644 test/groovy/com/sap/piper/versioning/PipArtifactVersioningTest.groovy create mode 100644 test/groovy/com/sap/piper/versioning/SbtArtifactVersioningTest.groovy create mode 100644 test/groovy/util/JenkinsWriteJsonRule.groovy create mode 100644 test/resources/versioning/DlangArtifactVersioning/dub.json create mode 100644 test/resources/versioning/GolangArtifactVersioning/VERSION create mode 100644 test/resources/versioning/NpmArtifactVersioning/package.json create mode 100644 test/resources/versioning/PipArtifactVersioning/version.txt create mode 100644 test/resources/versioning/SbtArtifactVersioning/sbtDescriptor.json diff --git a/documentation/docs/steps/artifactSetVersion.md b/documentation/docs/steps/artifactSetVersion.md index 95f8c9520..18a1e9c27 100644 --- a/documentation/docs/steps/artifactSetVersion.md +++ b/documentation/docs/steps/artifactSetVersion.md @@ -23,19 +23,19 @@ none | ----------|-----------|---------|-----------------| | script | no | empty `commonPipelineEnvironment` | | | artifactType | no | | 'appContainer' | -| buildTool | no | maven | maven, docker | +| buildTool | no | maven | docker, dlang, golang, maven, mta, npm, pip, sbt | | commitVersion | no | `true` | `true`, `false` | | dockerVersionSource | no | `''` | FROM, (ENV name),appVersion | -| filePath | no | buildTool=`maven`: pom.xml
docker: Dockerfile | | +| filePath | no | buildTool=`docker`: Dockerfile
buildTool=`dlang`: dub.json
buildTool=`golang`: VERSION
buildTool=`maven`: pom.xml
buildTool=`mta`: mta.yaml
buildTool=`npm`: package.json
buildTool=`pip`: version.txt
buildTool=`sbt`: sbtDescriptor.json| | | gitCommitId | no | `GitUtils.getGitCommitId()` | | -| gitCredentialsId | If `commitVersion` is `true` | as defined in custom configuration | | +| gitSshCredentialsId | If `commitVersion` is `true` | as defined in custom configuration | | | gitUserEMail | no | | | | gitUserName | no | | | | gitSshUrl | If `commitVersion` is `true` | | | | tagPrefix | no | 'build_' | | | timestamp | no | current time in format according to `timestampTemplate` | | | timestampTemplate | no | `%Y%m%d%H%M%S` | | -| versioningTemplate | no | depending on `buildTool`
maven: `${version}-${timestamp}${commitId?"_"+commitId:""}` | | +| versioningTemplate | no |buildTool=`docker`: `${version}-${timestamp}${commitId?"_"+commitId:""}`
/>buildTool=`dlang`: `${version}-${timestamp}${commitId?"+"+commitId:""}`
buildTool=`golang`:`${version}-${timestamp}${commitId?"+"+commitId:""}`
buildTool=`maven`: `${version}-${timestamp}${commitId?"_"+commitId:""}`
buildTool=`mta`: `${version}-${timestamp}${commitId?"+"+commitId:""}`
buildTool=`npm`: `${version}-${timestamp}${commitId?"+"+commitId:""}`
buildTool=`pip`: `${version}.${timestamp}${commitId?"."+commitId:""}`
buildTool=`sbt`: `${version}-${timestamp}${commitId?"+"+commitId:""}`| | * `script` defines the global script environment of the Jenkinsfile run. Typically `this` is passed to this parameter. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for retrieving e.g. configuration parameters. * `artifactType` defines the type of the artifact. @@ -49,7 +49,7 @@ none * Using `filePath` you could define a custom path to the descriptor file. * `gitCommitId` defines the version prefix of the automatically generated version. By default it will take the long commitId hash. You could pass any other string (e.g. the short commitId hash) to be used. In case you don't want to have the gitCommitId added to the automatic versioning string you could set the value to an empty string: `''`. -* `gitCredentialsId`defines the ssh git credentials to be used for writing the tag. +* `gitSshCredentialsId`defines the ssh git credentials to be used for writing the tag. * The parameters `gitUserName` and `gitUserEMail` allow to overwrite the global git settings available on your Jenkins server * `gitSshUrl` defines the git ssh url to the source code repository. * `tagPrefix` defines the prefix wich is used for the git tag which is written during the versioning run. diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml index a0c9101d7..39eb1de79 100644 --- a/resources/default_pipeline_environment.yml +++ b/resources/default_pipeline_environment.yml @@ -11,21 +11,37 @@ general: from: 'origin/master' to: 'HEAD' format: '%b' + gitSshKeyCredentialsId: '' #needed to allow sshagent to run with local ssh key #Steps Specific Configuration steps: artifactSetVersion: timestampTemplate: '%Y%m%d%H%M%S' tagPrefix: 'build_' commitVersion: true + dlang: + filePath: 'dub.json' + versioningTemplate: '${version}-${timestamp}${commitId?"+"+commitId:""}' docker: filePath: 'Dockerfile' versioningTemplate: '${version}-${timestamp}${commitId?"_"+commitId:""}' + golang: + filePath: 'VERSION' + versioningTemplate: '${version}-${timestamp}${commitId?"+"+commitId:""}' maven: filePath: 'pom.xml' versioningTemplate: '${version}-${timestamp}${commitId?"_"+commitId:""}' mta: filePath: 'mta.yaml' versioningTemplate: '${version}-${timestamp}${commitId?"+"+commitId:""}' + npm: + filePath: 'package.json' + versioningTemplate: '${version}-${timestamp}${commitId?"+"+commitId:""}' + pip: + filePath: 'version.txt' + versioningTemplate: '${version}.${timestamp}${commitId?"."+commitId:""}' + sbt: + filePath: 'sbtDescriptor.json' + versioningTemplate: '${version}-${timestamp}${commitId?"+"+commitId:""}' checksPublishResults: aggregation: active: true diff --git a/src/com/sap/piper/versioning/ArtifactVersioning.groovy b/src/com/sap/piper/versioning/ArtifactVersioning.groovy index 7cdfa744b..91168430e 100644 --- a/src/com/sap/piper/versioning/ArtifactVersioning.groovy +++ b/src/com/sap/piper/versioning/ArtifactVersioning.groovy @@ -12,12 +12,22 @@ abstract class ArtifactVersioning implements Serializable { public static getArtifactVersioning(buildTool, script, configuration) { switch (buildTool) { - case 'mta': - return new MtaArtifactVersioning(script, configuration) - case 'maven': - return new MavenArtifactVersioning(script, configuration) + case 'dlang': + return new DlangArtifactVersioning(script, configuration) case 'docker': return new DockerArtifactVersioning(script, configuration) + case 'golang': + return new GolangArtifactVersioning(script, configuration) + case 'maven': + return new MavenArtifactVersioning(script, configuration) + case 'mta': + return new MtaArtifactVersioning(script, configuration) + case 'npm': + return new NpmArtifactVersioning(script, configuration) + case 'pip': + return new PipArtifactVersioning(script, configuration) + case 'sbt': + return new SbtArtifactVersioning(script, configuration) default: throw new IllegalArgumentException("No versioning implementation for buildTool: ${buildTool} available.") } diff --git a/src/com/sap/piper/versioning/DlangArtifactVersioning.groovy b/src/com/sap/piper/versioning/DlangArtifactVersioning.groovy new file mode 100644 index 000000000..f8ff5da41 --- /dev/null +++ b/src/com/sap/piper/versioning/DlangArtifactVersioning.groovy @@ -0,0 +1,20 @@ +package com.sap.piper.versioning + +class DlangArtifactVersioning extends ArtifactVersioning { + protected DlangArtifactVersioning(script, configuration) { + super(script, configuration) + } + + @Override + def getVersion() { + def descriptor = script.readJSON file: configuration.filePath + return descriptor.version + } + + @Override + def setVersion(version) { + def descriptor = script.readJSON file: configuration.filePath + descriptor.version = new String(version) + script.writeJSON file: configuration.filePath, json: descriptor + } +} diff --git a/src/com/sap/piper/versioning/DockerArtifactVersioning.groovy b/src/com/sap/piper/versioning/DockerArtifactVersioning.groovy index 65cd4eabf..80faf20f1 100644 --- a/src/com/sap/piper/versioning/DockerArtifactVersioning.groovy +++ b/src/com/sap/piper/versioning/DockerArtifactVersioning.groovy @@ -5,13 +5,29 @@ class DockerArtifactVersioning extends ArtifactVersioning { super(script, configuration) } - @Override def getVersion() { - if (configuration.dockerVersionSource == 'FROM') - return getVersionFromDockerBaseImageTag(configuration.filePath) - else - //standard assumption: version is assigned to an env variable - return getVersionFromDockerEnvVariable(configuration.filePath, configuration.dockerVersionSource) + if(configuration.artifactType == 'appContainer' && configuration.dockerVersionSource == 'appVersion'){ + //replace + sign if available since + is not allowed in a Docker tag + if (script.commonPipelineEnvironment.getArtifactVersion()){ + return script.commonPipelineEnvironment.getArtifactVersion().replace('+', '_') + }else{ + throw new IllegalArgumentException("No artifact version available for 'dockerVersionSource: appVersion' -> executeBuild needs to run for the application artifact first to set the appVersion attribute.'") + } + } else if (configuration.dockerVersionSource == 'FROM') { + def version = getVersionFromDockerBaseImageTag(configuration.filePath) + if (version) { + return getVersionFromDockerBaseImageTag(configuration.filePath) + } else { + throw new IllegalArgumentException("No version information available in FROM statement") + } + } else { + def version = getVersionFromDockerEnvVariable(configuration.filePath, configuration.dockerVersionSource) + if (version) { + return version + } else { + throw new IllegalArgumentException("ENV variable '${configuration.dockerVersionSource}' not found.") + } + } } @Override diff --git a/src/com/sap/piper/versioning/GolangArtifactVersioning.groovy b/src/com/sap/piper/versioning/GolangArtifactVersioning.groovy new file mode 100644 index 000000000..17c7a6229 --- /dev/null +++ b/src/com/sap/piper/versioning/GolangArtifactVersioning.groovy @@ -0,0 +1,17 @@ +package com.sap.piper.versioning + +class GolangArtifactVersioning extends ArtifactVersioning { + protected GolangArtifactVersioning(script, configuration) { + super(script, configuration) + } + + @Override + def getVersion() { + return script.readFile(configuration.filePath).split('\n')[0].trim() + } + + @Override + def setVersion(version) { + script.writeFile file: configuration.filePath, text: version + } +} diff --git a/src/com/sap/piper/versioning/NpmArtifactVersioning.groovy b/src/com/sap/piper/versioning/NpmArtifactVersioning.groovy new file mode 100644 index 000000000..7f82e5736 --- /dev/null +++ b/src/com/sap/piper/versioning/NpmArtifactVersioning.groovy @@ -0,0 +1,20 @@ +package com.sap.piper.versioning + +class NpmArtifactVersioning extends ArtifactVersioning { + protected NpmArtifactVersioning(script, configuration) { + super(script, configuration) + } + + @Override + def getVersion() { + def packageJson = script.readJSON file: configuration.filePath + return packageJson.version + } + + @Override + def setVersion(version) { + def packageJson = script.readJSON file: configuration.filePath + packageJson.version = new String(version) + script.writeJSON file: configuration.filePath, json: packageJson + } +} diff --git a/src/com/sap/piper/versioning/PipArtifactVersioning.groovy b/src/com/sap/piper/versioning/PipArtifactVersioning.groovy new file mode 100644 index 000000000..920a934c9 --- /dev/null +++ b/src/com/sap/piper/versioning/PipArtifactVersioning.groovy @@ -0,0 +1,17 @@ +package com.sap.piper.versioning + +class PipArtifactVersioning extends ArtifactVersioning { + protected PipArtifactVersioning(script, configuration) { + super(script, configuration) + } + + @Override + def getVersion() { + return script.readFile(configuration.filePath).split('\n')[0].trim() + } + + @Override + def setVersion(version) { + script.writeFile file: configuration.filePath, text: version + } +} diff --git a/src/com/sap/piper/versioning/SbtArtifactVersioning.groovy b/src/com/sap/piper/versioning/SbtArtifactVersioning.groovy new file mode 100644 index 000000000..c3de70fec --- /dev/null +++ b/src/com/sap/piper/versioning/SbtArtifactVersioning.groovy @@ -0,0 +1,20 @@ +package com.sap.piper.versioning + +class SbtArtifactVersioning extends ArtifactVersioning { + protected SbtArtifactVersioning(script, configuration) { + super(script, configuration) + } + + @Override + def getVersion() { + def sbtDescriptorJson = script.readJSON file: configuration.filePath + return sbtDescriptorJson.version + } + + @Override + def setVersion(version) { + def sbtDescriptorJson = script.readJSON file: configuration.filePath + sbtDescriptorJson.version = new String(version) + script.writeJSON file: configuration.filePath, json: sbtDescriptorJson + } +} diff --git a/test/groovy/ArtifactSetVersionTest.groovy b/test/groovy/ArtifactSetVersionTest.groovy index 25717cad7..8d1ec8916 100644 --- a/test/groovy/ArtifactSetVersionTest.groovy +++ b/test/groovy/ArtifactSetVersionTest.groovy @@ -47,6 +47,9 @@ class ArtifactSetVersionTest extends BasePiperTest { void init() throws Throwable { dockerParameters = [:] + nullScript.commonPipelineEnvironment.setArtifactVersion(null) + nullScript.commonPipelineEnvironment.setGitSshUrl('git@test.url') + helper.registerAllowedMethod("sshagent", [List.class, Closure.class], { list, closure -> sshAgentList = list return closure() @@ -64,7 +67,7 @@ class ArtifactSetVersionTest extends BasePiperTest { @Test void testVersioning() { - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', gitSshUrl: 'myGitSshUrl') + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', gitSshUrl: 'myGitSshUrl') assertEquals('1.2.3-20180101010203_testCommitId', jer.env.getArtifactVersion()) assertEquals('testCommitId', jer.env.getGitCommitId()) @@ -79,7 +82,7 @@ class ArtifactSetVersionTest extends BasePiperTest { @Test void testVersioningWithoutCommit() { - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', commitVersion: false) + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', commitVersion: false) assertEquals('1.2.3-20180101010203_testCommitId', jer.env.getArtifactVersion()) assertThat(jscr.shell, hasItem("mvn --file 'pom.xml' --batch-mode -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn versions:set -DnewVersion=1.2.3-20180101010203_testCommitId")) @@ -87,7 +90,7 @@ class ArtifactSetVersionTest extends BasePiperTest { @Test void testVersioningWithoutScript() { - jsr.step.call(juStabGitUtils: gitUtils, buildTool: 'maven', commitVersion: false) + jsr.step.artifactSetVersion(juStabGitUtils: gitUtils, buildTool: 'maven', commitVersion: false) assertEquals('1.2.3-20180101010203_testCommitId', jer.env.getArtifactVersion()) assertThat(jscr.shell, hasItem("mvn --file 'pom.xml' --batch-mode -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn versions:set -DnewVersion=1.2.3-20180101010203_testCommitId")) @@ -95,14 +98,14 @@ class ArtifactSetVersionTest extends BasePiperTest { @Test void testVersioningCustomGitUserAndEMail() { - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', gitSshUrl: 'myGitSshUrl', gitUserEMail: 'test@test.com', gitUserName: 'test') + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', gitSshUrl: 'myGitSshUrl', gitUserEMail: 'test@test.com', gitUserName: 'test') assertThat(jscr.shell, hasItem("git -c user.email=\"test@test.com\" -c user.name=\"test\" commit -m 'update version 1.2.3-20180101010203_testCommitId'")) } @Test void testVersioningWithTimestamp() { - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', timestamp: '2018') + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', timestamp: '2018') assertEquals('1.2.3-2018_testCommitId', jer.env.getArtifactVersion()) } @@ -110,23 +113,35 @@ class ArtifactSetVersionTest extends BasePiperTest { void testVersioningNoBuildTool() { thrown.expect(Exception) thrown.expectMessage('ERROR - NO VALUE AVAILABLE FOR buildTool') - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils) + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils) } @Test void testVersioningWithCustomTemplate() { - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', versioningTemplate: '${version}-xyz') + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'maven', versioningTemplate: '${version}-xyz') assertEquals('1.2.3-xyz', jer.env.getArtifactVersion()) } @Test void testVersioningWithTypeAppContainer() { + nullScript.commonPipelineEnvironment.setAppContainerProperty('gitSshUrl', 'git@test.url') jer.env.setArtifactVersion('1.2.3-xyz') - jsr.step.call(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'docker', artifactType: 'appContainer', dockerVersionSource: 'appVersion') + jsr.step.artifactSetVersion(script: jsr.step, juStabGitUtils: gitUtils, buildTool: 'docker', artifactType: 'appContainer', dockerVersionSource: 'appVersion') assertEquals('1.2.3-xyz', jer.env.getArtifactVersion()) assertEquals('1.2.3-xyz', jwfr.files['VERSION']) } + @Test + void testCredentialCompatibility() { + jsr.step.artifactSetVersion ( + script: nullScript, + buildTool: 'maven', + gitCredentialsId: 'testCredentials', + juStabGitUtils: gitUtils + ) + assertThat(sshAgentList, hasItem('testCredentials')) + } + void prepareObjectInterceptors(object) { object.metaClass.invokeMethod = helper.getMethodInterceptor() object.metaClass.static.invokeMethod = helper.getMethodInterceptor() diff --git a/test/groovy/com/sap/piper/versioning/DlangArtifactVersioningTest.groovy b/test/groovy/com/sap/piper/versioning/DlangArtifactVersioningTest.groovy new file mode 100644 index 000000000..06f0e9a9b --- /dev/null +++ b/test/groovy/com/sap/piper/versioning/DlangArtifactVersioningTest.groovy @@ -0,0 +1,32 @@ +package com.sap.piper.versioning + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsReadJsonRule +import util.JenkinsWriteJsonRule +import util.Rules + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertTrue + +class DlangArtifactVersioningTest extends BasePiperTest{ + + JenkinsReadJsonRule jrjr = new JenkinsReadJsonRule(this, 'test/resources/versioning/DlangArtifactVersioning/') + JenkinsWriteJsonRule jwjr = new JenkinsWriteJsonRule(this) + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(jrjr) + .around(jwjr) + + @Test + void testVersioning() { + DlangArtifactVersioning av = new DlangArtifactVersioning(nullScript, [filePath: 'dub.json']) + assertEquals('1.2.3', av.getVersion()) + av.setVersion('1.2.3-20180101') + assertTrue(jwjr.files['dub.json'].contains('1.2.3-20180101')) + } +} diff --git a/test/groovy/com/sap/piper/versioning/GolangArtifactVersioningTest.groovy b/test/groovy/com/sap/piper/versioning/GolangArtifactVersioningTest.groovy new file mode 100644 index 000000000..4c12bd269 --- /dev/null +++ b/test/groovy/com/sap/piper/versioning/GolangArtifactVersioningTest.groovy @@ -0,0 +1,31 @@ +package com.sap.piper.versioning + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsReadFileRule +import util.JenkinsWriteFileRule +import util.Rules + +import static org.junit.Assert.assertEquals + +class GolangArtifactVersioningTest extends BasePiperTest{ + + JenkinsReadFileRule jrfr = new JenkinsReadFileRule(this, 'test/resources/versioning/GolangArtifactVersioning/') + JenkinsWriteFileRule jwfr = new JenkinsWriteFileRule(this) + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(jrfr) + .around(jwfr) + + @Test + void testVersioning() { + GolangArtifactVersioning av = new GolangArtifactVersioning(nullScript, [filePath: 'VERSION']) + assertEquals('1.2.3', av.getVersion()) + av.setVersion('1.2.3-20180101') + assertEquals('1.2.3-20180101', jwfr.files['VERSION']) + } +} diff --git a/test/groovy/com/sap/piper/versioning/NpmArtifactVersioningTest.groovy b/test/groovy/com/sap/piper/versioning/NpmArtifactVersioningTest.groovy new file mode 100644 index 000000000..8e0e25305 --- /dev/null +++ b/test/groovy/com/sap/piper/versioning/NpmArtifactVersioningTest.groovy @@ -0,0 +1,32 @@ +package com.sap.piper.versioning + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsReadJsonRule +import util.JenkinsWriteJsonRule +import util.Rules + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertTrue + +class NpmArtifactVersioningTest extends BasePiperTest{ + + JenkinsReadJsonRule jrjr = new JenkinsReadJsonRule(this, 'test/resources/versioning/NpmArtifactVersioning/') + JenkinsWriteJsonRule jwjr = new JenkinsWriteJsonRule(this) + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(jrjr) + .around(jwjr) + + @Test + void testVersioning() { + NpmArtifactVersioning av = new NpmArtifactVersioning(nullScript, [filePath: 'package.json']) + assertEquals('1.2.3', av.getVersion()) + av.setVersion('1.2.3-20180101') + assertTrue(jwjr.files['package.json'].contains('1.2.3-20180101')) + } +} diff --git a/test/groovy/com/sap/piper/versioning/PipArtifactVersioningTest.groovy b/test/groovy/com/sap/piper/versioning/PipArtifactVersioningTest.groovy new file mode 100644 index 000000000..8332ee4cf --- /dev/null +++ b/test/groovy/com/sap/piper/versioning/PipArtifactVersioningTest.groovy @@ -0,0 +1,31 @@ +package com.sap.piper.versioning + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsReadFileRule +import util.JenkinsWriteFileRule +import util.Rules + +import static org.junit.Assert.assertEquals + +class PipArtifactVersioningTest extends BasePiperTest{ + + JenkinsReadFileRule jrfr = new JenkinsReadFileRule(this, 'test/resources/versioning/PipArtifactVersioning/') + JenkinsWriteFileRule jwfr = new JenkinsWriteFileRule(this) + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(jrfr) + .around(jwfr) + + @Test + void testVersioning() { + PipArtifactVersioning av = new PipArtifactVersioning(nullScript, [filePath: 'version.txt']) + assertEquals('1.2.3', av.getVersion()) + av.setVersion('1.2.3-20180101') + assertEquals('1.2.3-20180101', jwfr.files['version.txt']) + } +} diff --git a/test/groovy/com/sap/piper/versioning/SbtArtifactVersioningTest.groovy b/test/groovy/com/sap/piper/versioning/SbtArtifactVersioningTest.groovy new file mode 100644 index 000000000..e94b4ba88 --- /dev/null +++ b/test/groovy/com/sap/piper/versioning/SbtArtifactVersioningTest.groovy @@ -0,0 +1,32 @@ +package com.sap.piper.versioning + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import util.BasePiperTest +import util.JenkinsReadJsonRule +import util.JenkinsWriteJsonRule +import util.Rules + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertTrue + +class SbtArtifactVersioningTest extends BasePiperTest{ + + JenkinsReadJsonRule jrjr = new JenkinsReadJsonRule(this, 'test/resources/versioning/SbtArtifactVersioning/') + JenkinsWriteJsonRule jwjr = new JenkinsWriteJsonRule(this) + + @Rule + public RuleChain ruleChain = Rules + .getCommonRules(this) + .around(jrjr) + .around(jwjr) + + @Test + void testVersioning() { + SbtArtifactVersioning av = new SbtArtifactVersioning(nullScript, [filePath: 'sbtDescriptor.json']) + assertEquals('1.2.3', av.getVersion()) + av.setVersion('1.2.3-20180101') + assertTrue(jwjr.files['sbtDescriptor.json'].contains('1.2.3-20180101')) + } +} diff --git a/test/groovy/util/JenkinsWriteJsonRule.groovy b/test/groovy/util/JenkinsWriteJsonRule.groovy new file mode 100644 index 000000000..3c012a4bd --- /dev/null +++ b/test/groovy/util/JenkinsWriteJsonRule.groovy @@ -0,0 +1,34 @@ +package util + +import com.lesfurets.jenkins.unit.BasePipelineTest +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class JenkinsWriteJsonRule implements TestRule { + + final BasePipelineTest testInstance + + Map files = [:] + + JenkinsWriteJsonRule(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( 'writeJSON', [Map.class], {m -> files[m.file] = m.json.toString()}) + + base.evaluate() + } + } + } +} diff --git a/test/resources/versioning/DlangArtifactVersioning/dub.json b/test/resources/versioning/DlangArtifactVersioning/dub.json new file mode 100644 index 000000000..acfc33694 --- /dev/null +++ b/test/resources/versioning/DlangArtifactVersioning/dub.json @@ -0,0 +1,5 @@ +{ + "name": "sap-pipeline-test", + "version": "1.2.3", + "description": "package.json for testing", +} diff --git a/test/resources/versioning/GolangArtifactVersioning/VERSION b/test/resources/versioning/GolangArtifactVersioning/VERSION new file mode 100644 index 000000000..0495c4a88 --- /dev/null +++ b/test/resources/versioning/GolangArtifactVersioning/VERSION @@ -0,0 +1 @@ +1.2.3 diff --git a/test/resources/versioning/NpmArtifactVersioning/package.json b/test/resources/versioning/NpmArtifactVersioning/package.json new file mode 100644 index 000000000..05122c999 --- /dev/null +++ b/test/resources/versioning/NpmArtifactVersioning/package.json @@ -0,0 +1,15 @@ +{ + "name": "sap-pipeline-test", + "version": "1.2.3", + "private": false, + "description": "package.json for testing", + "engines": { + "node": "6.x" + }, + "dependencies": { + "apn": "1.7.8" + }, + "devDependencies": { + "chai": "^3.4.1" + } +} diff --git a/test/resources/versioning/PipArtifactVersioning/version.txt b/test/resources/versioning/PipArtifactVersioning/version.txt new file mode 100644 index 000000000..0495c4a88 --- /dev/null +++ b/test/resources/versioning/PipArtifactVersioning/version.txt @@ -0,0 +1 @@ +1.2.3 diff --git a/test/resources/versioning/SbtArtifactVersioning/sbtDescriptor.json b/test/resources/versioning/SbtArtifactVersioning/sbtDescriptor.json new file mode 100644 index 000000000..ea5c8cb34 --- /dev/null +++ b/test/resources/versioning/SbtArtifactVersioning/sbtDescriptor.json @@ -0,0 +1,4 @@ +{ + "name": "sap-pipeline-test", + "version": "1.2.3", +} diff --git a/vars/artifactSetVersion.groovy b/vars/artifactSetVersion.groovy index 587f5fd34..4b46a536d 100644 --- a/vars/artifactSetVersion.groovy +++ b/vars/artifactSetVersion.groovy @@ -8,13 +8,14 @@ import groovy.text.SimpleTemplateEngine @Field String STEP_NAME = 'artifactSetVersion' @Field Set GENERAL_CONFIG_KEYS = ['collectTelemetryData'] +@Field Map CONFIG_KEY_COMPATIBILITY = [gitSshKeyCredentialsId: 'gitCredentialsId'] @Field Set STEP_CONFIG_KEYS = [ 'artifactType', 'buildTool', 'commitVersion', 'dockerVersionSource', 'filePath', - 'gitCredentialsId', + 'gitSshKeyCredentialsId', 'gitUserEMail', 'gitUserName', 'gitSshUrl', @@ -25,7 +26,7 @@ import groovy.text.SimpleTemplateEngine ] @Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS.plus('gitCommitId') -def call(Map parameters = [:]) { +def call(Map parameters = [:], Closure body = null) { handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { @@ -43,49 +44,45 @@ def call(Map parameters = [:]) { // load default & individual configuration Map config = ConfigurationHelper .loadStepDefaults(this) - .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) - .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) - .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS) + .mixinGeneralConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) + .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS, this, CONFIG_KEY_COMPATIBILITY) .mixin(gitCommitId: gitUtils.getGitCommitIdOrNull()) - .mixin(parameters, PARAMETER_KEYS) + .mixin(parameters, PARAMETER_KEYS, this, CONFIG_KEY_COMPATIBILITY) .withMandatoryProperty('buildTool') + .dependingOn('buildTool').mixin('filePath') + .dependingOn('buildTool').mixin('versioningTemplate') + .use() + + config = new ConfigurationHelper(config) + .addIfEmpty('gitSshUrl', (config.buildTool == 'docker' && config.artifactType == 'appContainer')?script.commonPipelineEnvironment.getAppContainerProperty('gitSshUrl'):script.commonPipelineEnvironment.getGitSshUrl()) + .addIfEmpty('timestamp', getTimestamp(config.timestampTemplate)) + .withMandatoryProperty('gitSshUrl') .use() new Utils().pushToSWA([step: STEP_NAME, stepParam1: config.buildTool], config) - if (!config.filePath) - config.filePath = config[config.buildTool].filePath //use default configuration + def artifactVersioning = ArtifactVersioning.getArtifactVersioning(config.buildTool, script, config) + def currentVersion = artifactVersioning.getVersion() def newVersion - def artifactVersioning = ArtifactVersioning.getArtifactVersioning(config.buildTool, script, config) - - if(config.artifactType == 'appContainer' && config.dockerVersionSource == 'appVersion'){ - if (script.commonPipelineEnvironment.getArtifactVersion()) - //replace + sign if available since + is not allowed in a Docker tag - newVersion = script.commonPipelineEnvironment.getArtifactVersion().replace('+', '_') - else - error ("[${STEP_NAME}] No artifact version available for 'dockerVersionSource: appVersion' -> executeBuild needs to run for the application artifact first to set the artifactVersion for the application artifact.'") + if (config.artifactType == 'appContainer' && config.dockerVersionSource == 'appVersion'){ + newVersion = currentVersion } else { - def currentVersion = artifactVersioning.getVersion() - - def timestamp = config.timestamp ? config.timestamp : getTimestamp(config.timestampTemplate) - - def versioningTemplate = config.versioningTemplate ? config.versioningTemplate : config[config.buildTool].versioningTemplate - //defined in default configuration - def binding = [version: currentVersion, timestamp: timestamp, commitId: config.gitCommitId] - def templatingEngine = new SimpleTemplateEngine() - def template = templatingEngine.createTemplate(versioningTemplate).make(binding) - newVersion = template.toString() + def binding = [version: currentVersion, timestamp: config.timestamp, commitId: config.gitCommitId] + newVersion = new SimpleTemplateEngine().createTemplate(config.versioningTemplate).make(binding).toString() } artifactVersioning.setVersion(newVersion) - def gitCommitId + if(body != null){ + body(newVersion) + } if (config.commitVersion) { sh 'git add .' - sshagent([config.gitCredentialsId]) { + sshagent([config.gitSshKeyCredentialsId]) { def gitUserMailConfig = '' if (config.gitUserName && config.gitUserEMail) gitUserMailConfig = "-c user.email=\"${config.gitUserEMail}\" -c user.name=\"${config.gitUserName}\"" @@ -99,17 +96,17 @@ def call(Map parameters = [:]) { sh "git tag ${config.tagPrefix}${newVersion}" sh "git push origin ${config.tagPrefix}${newVersion}" - gitCommitId = gitUtils.getGitCommitIdOrNull() + config.gitCommitId = gitUtils.getGitCommitIdOrNull() } } if (config.buildTool == 'docker' && config.artifactType == 'appContainer') { script.commonPipelineEnvironment.setAppContainerProperty('artifactVersion', newVersion) - script.commonPipelineEnvironment.setAppContainerProperty('gitCommitId', gitCommitId) + script.commonPipelineEnvironment.setAppContainerProperty('gitCommitId', config.gitCommitId) } else { //standard case script.commonPipelineEnvironment.setArtifactVersion(newVersion) - script.commonPipelineEnvironment.setGitCommitId(gitCommitId) + script.commonPipelineEnvironment.setGitCommitId(config.gitCommitId) } echo "[${STEP_NAME}]New version: ${newVersion}" From 9dfc7fcd01b71c631a5bad5f58dc13cc9ad602e7 Mon Sep 17 00:00:00 2001 From: Thorsten Willenbacher Date: Mon, 16 Jul 2018 15:41:46 +0200 Subject: [PATCH 23/24] refactor withCredentials to ChangeManagement util class fix tests to match refactoring --- src/com/sap/piper/cm/ChangeManagement.groovy | 80 ++++++++++--------- .../CheckChangeInDevelopmentTest.groovy | 13 ++- test/groovy/TransportRequestCreateTest.groovy | 14 ++-- .../TransportRequestUploadFileTest.groovy | 21 ++--- .../sap/piper/cm/ChangeManagementTest.groovy | 24 +++--- vars/checkChangeInDevelopment.groovy | 25 +++--- vars/transportRequestCreate.groovy | 10 +-- vars/transportRequestRelease.groovy | 11 +-- vars/transportRequestUploadFile.groovy | 13 ++- 9 files changed, 90 insertions(+), 121 deletions(-) diff --git a/src/com/sap/piper/cm/ChangeManagement.groovy b/src/com/sap/piper/cm/ChangeManagement.groovy index 27f443c26..08d4472fc 100644 --- a/src/com/sap/piper/cm/ChangeManagement.groovy +++ b/src/com/sap/piper/cm/ChangeManagement.groovy @@ -64,60 +64,62 @@ public class ChangeManagement implements Serializable { return items[0] } - boolean isChangeInDevelopment(String changeId, String endpoint, String username, String password, String clientOpts = '') { + boolean isChangeInDevelopment(String changeId, String endpoint, String credentialsId, String clientOpts = '') { + int rc = executeWithCredentials(endpoint, credentialsId, 'is-change-in-development', ['-cID', "'${changeId}'", '--return-code'], + clientOpts) as int - int rc = script.sh(returnStatus: true, - script: getCMCommandLine(endpoint, username, password, - 'is-change-in-development', ['-cID', "'${changeId}'", - '--return-code'], - clientOpts)) - - if(rc == 0) { - return true - } else if(rc == 3) { - return false - } else { - throw new ChangeManagementException("Cannot retrieve status for change document '${changeId}'. Does this change exist? Return code from cmclient: ${rc}.") - } - } - - String createTransportRequest(String changeId, String developmentSystemId, String endpoint, String username, String password, String clientOpts = '') { - - try { - String transportRequest = script.sh(returnStdout: true, - script: getCMCommandLine(endpoint, username, password, 'create-transport', ['-cID', changeId, - '-dID', developmentSystemId], - clientOpts)) - return transportRequest.trim() - } catch(AbortException e) { - throw new ChangeManagementException("Cannot create a transport request for change id '$changeId'. $e.message.") + if (rc == 0) { + return true + } else if (rc == 3) { + return false + } else { + throw new ChangeManagementException("Cannot retrieve status for change document '${changeId}'. Does this change exist? Return code from cmclient: ${rc}.") } } - void uploadFileToTransportRequest(String changeId, String transportRequestId, String applicationId, String filePath, String endpoint, String username, String password, String cmclientOpts = '') { + String createTransportRequest(String changeId, String developmentSystemId, String endpoint, String credentialsId, String clientOpts = '') { + try { + def transportRequest = executeWithCredentials(endpoint, credentialsId, 'create-transport', ['-cID', changeId, '-dID', developmentSystemId], + clientOpts) + return transportRequest.trim() as String + }catch(AbortException e) { + throw new ChangeManagementException("Cannot create a transport request for change id '$changeId'. $e.message.") + } + } - int rc = script.sh(returnStatus: true, - script: getCMCommandLine(endpoint, username, password, - 'upload-file-to-transport', ['-cID', changeId, - '-tID', transportRequestId, - applicationId, filePath], - cmclientOpts)) + + void uploadFileToTransportRequest(String changeId, String transportRequestId, String applicationId, String filePath, String endpoint, String credentialsId, String cmclientOpts = '') { + int rc = executeWithCredentials(endpoint, credentialsId, 'upload-file-to-transport', ['-cID', changeId, + '-tID', transportRequestId, + applicationId, filePath], + cmclientOpts) as int if(rc == 0) { return } else { throw new ChangeManagementException("Cannot upload file '$filePath' for change document '$changeId' with transport request '$transportRequestId'. Return code from cmclient: $rc.") } + } - void releaseTransportRequest(String changeId, String transportRequestId, String endpoint, String username, String password, String clientOpts = '') { + def executeWithCredentials(String endpoint, String credentialsId, String command, List args, String clientOpts = '') { + script.withCredentials([script.usernamePassword( + credentialsId: credentialsId, + passwordVariable: 'password', + usernameVariable: 'username')]) { + def returnValue = script.sh(returnStatus: true, + script: getCMCommandLine(endpoint, script.username, script.password, + command, args, + clientOpts)) + return returnValue; - int rc = script.sh(returnStatus: true, - script: getCMCommandLine(endpoint, username, password, - 'release-transport', ['-cID', changeId, - '-tID', transportRequestId], - clientOpts)) + } + } + + void releaseTransportRequest(String changeId, String transportRequestId, String endpoint, String credentialsId, String clientOpts = '') { + int rc = executeWithCredentials( endpoint, credentialsId, 'release-transport', ['-cID', changeId, + '-tID', transportRequestId], clientOpts) as int if(rc == 0) { return } else { diff --git a/test/groovy/CheckChangeInDevelopmentTest.groovy b/test/groovy/CheckChangeInDevelopmentTest.groovy index 7ecba5bf3..6aa783415 100644 --- a/test/groovy/CheckChangeInDevelopmentTest.groovy +++ b/test/groovy/CheckChangeInDevelopmentTest.groovy @@ -1,5 +1,5 @@ import org.junit.After -import org.junit.Before + import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException @@ -26,7 +26,7 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { .around(thrown) .around(jsr) .around(new JenkinsCredentialsRule(this) - .withCredentials('CM', 'anonymous', '********')) + .withCredentials('CM', 'anonymous', '********')) @After public void tearDown() { @@ -44,12 +44,10 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { changeManagement: [endpoint: 'https://example.org/cm']) assert inDevelopment - assert cmUtilReceivedParams == [ changeId: '001', endpoint: 'https://example.org/cm', - userName: 'anonymous', - password: '********', + credentialsId: 'CM', cmclientOpts: '' ] } @@ -163,11 +161,10 @@ class CheckChangeInDevelopmentTest extends BasePiperTest { return changeDocumentId } - boolean isChangeInDevelopment(String changeId, String endpoint, String userName, String password, String cmclientOpts) { + boolean isChangeInDevelopment(String changeId, String endpoint, String credentialsId, String cmclientOpts) { cmUtilReceivedParams.changeId = changeId cmUtilReceivedParams.endpoint = endpoint - cmUtilReceivedParams.userName = userName - cmUtilReceivedParams.password = password + cmUtilReceivedParams.credentialsId = credentialsId cmUtilReceivedParams.cmclientOpts = cmclientOpts return inDevelopment diff --git a/test/groovy/TransportRequestCreateTest.groovy b/test/groovy/TransportRequestCreateTest.groovy index 297f3f7bf..56117d938 100644 --- a/test/groovy/TransportRequestCreateTest.groovy +++ b/test/groovy/TransportRequestCreateTest.groovy @@ -36,6 +36,7 @@ public class TransportRequestCreateTest extends BasePiperTest { nullScript.commonPipelineEnvironment.configuration = [general: [changeManagement: + [ credentialsId: 'CM', endpoint: 'https://example.org/cm', @@ -85,8 +86,7 @@ public class TransportRequestCreateTest extends BasePiperTest { String createTransportRequest(String changeId, String developmentSystemId, String cmEndpoint, - String username, - String password, + String credentialId, String clientOpts) { throw new ChangeManagementException('Exception message.') @@ -110,15 +110,14 @@ public class TransportRequestCreateTest extends BasePiperTest { String createTransportRequest(String changeId, String developmentSystemId, String cmEndpoint, - String username, - String password, + String credentialId, String clientOpts) { result.changeId = changeId result.developmentSystemId = developmentSystemId result.cmEndpoint = cmEndpoint - result.username = username - result.password = password + result.credentialId = credentialId + result.clientOpts = clientOpts return '001' } @@ -130,8 +129,7 @@ public class TransportRequestCreateTest extends BasePiperTest { assert result == [changeId: '001', developmentSystemId: '001', cmEndpoint: 'https://example.org/cm', - username: 'anonymous', - password: '********', + credentialId: 'CM', clientOpts: '-DmyProp=myVal' ] diff --git a/test/groovy/TransportRequestUploadFileTest.groovy b/test/groovy/TransportRequestUploadFileTest.groovy index 4b3c9306c..ad0712fdc 100644 --- a/test/groovy/TransportRequestUploadFileTest.groovy +++ b/test/groovy/TransportRequestUploadFileTest.groovy @@ -116,8 +116,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { String applicationId, String filePath, String endpoint, - String username, - String password, + String credentialsId, String cmclientOpts) { throw new ChangeManagementException('Exception message') } @@ -146,8 +145,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { String applicationId, String filePath, String endpoint, - String username, - String password, + String credentialsId, String cmclientOpts) { cmUtilReceivedParams.changeId = changeId @@ -155,8 +153,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { cmUtilReceivedParams.applicationId = applicationId cmUtilReceivedParams.filePath = filePath cmUtilReceivedParams.endpoint = endpoint - cmUtilReceivedParams.username = username - cmUtilReceivedParams.password = password + cmUtilReceivedParams.credentialsId = credentialsId cmUtilReceivedParams.cmclientOpts = cmclientOpts } } @@ -175,8 +172,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { applicationId: 'app', filePath: '/path', endpoint: 'https://example.org/cm', - username: 'anonymous', - password: '********', + credentialsId: 'CM', cmclientOpts: '' ] } @@ -193,8 +189,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { String applicationId, String filePath, String endpoint, - String username, - String password, + String credentialsId, String cmclientOpts) { cmUtilReceivedParams.filePath = filePath @@ -223,8 +218,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { String applicationId, String filePath, String endpoint, - String username, - String password, + String credentialsId, String cmclientOpts) { cmUtilReceivedParams.filePath = filePath @@ -252,8 +246,7 @@ public class TransportRequestUploadFileTest extends BasePiperTest { String applicationId, String filePath, String endpoint, - String username, - String password, + String credentialsId, String cmclientOpts) { throw new ChangeManagementException('Upload failure.') } diff --git a/test/groovy/com/sap/piper/cm/ChangeManagementTest.groovy b/test/groovy/com/sap/piper/cm/ChangeManagementTest.groovy index dfeef8d33..edf556fe7 100644 --- a/test/groovy/com/sap/piper/cm/ChangeManagementTest.groovy +++ b/test/groovy/com/sap/piper/cm/ChangeManagementTest.groovy @@ -21,6 +21,7 @@ import util.BasePiperTest import util.JenkinsLoggingRule import util.JenkinsScriptLoaderRule import util.JenkinsShellCallRule +import util.JenkinsCredentialsRule import util.Rules import hudson.AbortException @@ -37,6 +38,7 @@ public class ChangeManagementTest extends BasePiperTest { .around(thrown) .around(script) .around(logging) + .around(new JenkinsCredentialsRule(this).withCredentials('me','user','password')) @Test public void testRetrieveChangeDocumentIdOutsideGitWorkTreeTest() { @@ -90,8 +92,7 @@ public class ChangeManagementTest extends BasePiperTest { public void testIsChangeInDevelopmentReturnsTrueWhenChangeIsInDevelopent() { script.setReturnValue(JenkinsShellCallRule.Type.REGEX, "cmclient.*is-change-in-development -cID '001'", 0) - - boolean inDevelopment = new ChangeManagement(nullScript, null).isChangeInDevelopment('001', 'endpoint', 'user', 'password') + boolean inDevelopment = new ChangeManagement(nullScript, null).isChangeInDevelopment('001', 'endpoint', 'me') assertThat(inDevelopment, is(equalTo(true))) assertThat(script.shell[0], allOf(containsString("cmclient"), @@ -111,8 +112,7 @@ public class ChangeManagementTest extends BasePiperTest { boolean inDevelopment = new ChangeManagement(nullScript, null) .isChangeInDevelopment('001', 'endpoint', - 'user', - 'password') + 'me') assertThat(inDevelopment, is(equalTo(false))) } @@ -124,8 +124,7 @@ public class ChangeManagementTest extends BasePiperTest { thrown.expectMessage('Cannot retrieve status for change document \'001\'. Does this change exist? Return code from cmclient: 1.') script.setReturnValue(JenkinsShellCallRule.Type.REGEX, "cmclient.*is-change-in-development -cID '001'", 1) - - new ChangeManagement(nullScript, null).isChangeInDevelopment('001', 'endpoint', 'user', 'password') + new ChangeManagement(nullScript, null).isChangeInDevelopment('001', 'endpoint', 'me') } @Test @@ -139,7 +138,7 @@ public class ChangeManagementTest extends BasePiperTest { commandLine = commandLine.replaceAll(' +', " ") assertThat(commandLine, not(containsString("CMCLIENT_OPTS"))) assertThat(commandLine, containsString("cmclient -e 'https://example.org/cm' -u 'me' -p 'topSecret' -t SOLMAN the-command -key1 val1 -key2 val2")) -} + } @Test public void testGetCommandLineWithCMClientOpts() { @@ -158,7 +157,7 @@ public void testGetCommandLineWithCMClientOpts() { public void testCreateTransportRequestSucceeds() { script.setReturnValue(JenkinsShellCallRule.Type.REGEX, ".*cmclient.*create-transport -cID 001 -dID 002.*", '004') - def transportRequestId = new ChangeManagement(nullScript).createTransportRequest('001', '002', '003', 'me', 'openSesame') + def transportRequestId = new ChangeManagement(nullScript).createTransportRequest('001', '002', '003', 'me') // the check for the transportRequestID is sufficient. This checks implicit the command line since that value is // returned only in case the shell call matches. @@ -180,8 +179,7 @@ public void testGetCommandLineWithCMClientOpts() { 'XXX', '/path', 'https://example.org/cm', - 'me', - 'openSesame') + 'me') } @Test @@ -195,8 +193,7 @@ public void testGetCommandLineWithCMClientOpts() { 'XXX', '/path', 'https://example.org/cm', - 'me', - 'openSesame') + 'me') // no assert required here, since the regex registered above to the script rule is an implicit check for // the command line. @@ -216,8 +213,7 @@ public void testGetCommandLineWithCMClientOpts() { 'XXX', '/path', 'https://example.org/cm', - 'me', - 'openSesame') + 'me') } private GitUtils gitUtilsMock(boolean insideWorkTree, String[] changeIds) { diff --git a/vars/checkChangeInDevelopment.groovy b/vars/checkChangeInDevelopment.groovy index f6838e068..ca94d8bd3 100644 --- a/vars/checkChangeInDevelopment.groovy +++ b/vars/checkChangeInDevelopment.groovy @@ -52,7 +52,7 @@ def call(parameters = [:]) { if(changeId?.trim()) { - echo "[INFO] ChangeDocumentId retrieved from parameters." + echo "[INFO] ChangeDocumentId retrieved from parameters." } else { @@ -85,22 +85,19 @@ def call(parameters = [:]) { echo "[INFO] Checking if change document '${configuration.changeDocumentId}' is in development." - withCredentials([usernamePassword( - credentialsId: configuration.changeManagement.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username')]) { + try { - try { - isInDevelopment = cm.isChangeInDevelopment(configuration.changeDocumentId, - configuration.changeManagement.endpoint, - username, - password, - configuration.changeManagement.clientOpts) - } catch(ChangeManagementException ex) { - throw new AbortException(ex.getMessage()) - } + + isInDevelopment = cm.isChangeInDevelopment(configuration.changeDocumentId, + configuration.changeManagement.endpoint, + configuration.changeManagement.credentialsId, + configuration.changeManagement.clientOpts) + + } catch(ChangeManagementException ex) { + throw new AbortException(ex.getMessage()) } + if(isInDevelopment) { echo "[INFO] Change '${changeId}' is in status 'in development'." return true diff --git a/vars/transportRequestCreate.groovy b/vars/transportRequestCreate.groovy index 88899b93d..c675f8706 100644 --- a/vars/transportRequestCreate.groovy +++ b/vars/transportRequestCreate.groovy @@ -79,22 +79,16 @@ def call(parameters = [:]) { echo "[INFO] Creating transport request for change document '${configuration.changeDocumentId}' and development system '${configuration.developmentSystemId}'." - withCredentials([usernamePassword( - credentialsId: configuration.changeManagement.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username')]) { - try { transportRequestId = cm.createTransportRequest(configuration.changeDocumentId, configuration.developmentSystemId, configuration.changeManagement.endpoint, - username, - password, + configuration.changeManagement.credentialsId, configuration.changeManagement.clientOpts) } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } - } + echo "[INFO] Transport Request '$transportRequestId' has been successfully created." return transportRequestId diff --git a/vars/transportRequestRelease.groovy b/vars/transportRequestRelease.groovy index 8e399766d..81ea5cdc7 100644 --- a/vars/transportRequestRelease.groovy +++ b/vars/transportRequestRelease.groovy @@ -108,22 +108,17 @@ def call(parameters = [:]) { echo "[INFO] Closing transport request '${configuration.transportRequestId}' for change document '${configuration.changeDocumentId}'." - withCredentials([usernamePassword( - credentialsId: configuration.changeManagement.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username')]) { - try { cm.releaseTransportRequest(configuration.changeDocumentId, configuration.transportRequestId, configuration.changeManagement.endpoint, - username, - password, + configuration.changeManagement.credentialsId, configuration.changeManagement.clientOpts) + } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } - } + echo "[INFO] Transport Request '${configuration.transportRequestId}' has been successfully closed." } diff --git a/vars/transportRequestUploadFile.groovy b/vars/transportRequestUploadFile.groovy index 130597477..edc6108aa 100644 --- a/vars/transportRequestUploadFile.groovy +++ b/vars/transportRequestUploadFile.groovy @@ -113,24 +113,21 @@ def call(parameters = [:]) { echo "[INFO] Uploading file '${configuration.filePath}' to transport request '${configuration.transportRequestId}' of change document '${configuration.changeDocumentId}'." - withCredentials([usernamePassword( - credentialsId: configuration.changeManagement.credentialsId, - passwordVariable: 'password', - usernameVariable: 'username')]) { - try { + + cm.uploadFileToTransportRequest(configuration.changeDocumentId, configuration.transportRequestId, configuration.applicationId, configuration.filePath, configuration.changeManagement.endpoint, - username, - password, + configuration.changeManagement.credentialsId, configuration.changeManagement.clientOpts) + } catch(ChangeManagementException ex) { throw new AbortException(ex.getMessage()) } - } + echo "[INFO] File '${configuration.filePath}' has been successfully uploaded to transport request '${configuration.transportRequestId}' of change document '${configuration.changeDocumentId}'." } From 71f7f05427814bc85437316be66d5c0dcf4617ae Mon Sep 17 00:00:00 2001 From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> Date: Thu, 9 Aug 2018 11:35:33 +0200 Subject: [PATCH 24/24] add telemetry reporting to steps (#243) add telemetry to all steps using ConfigurationHelper. Other steps need to be switched to ConfigurationHelper first. update docs --- documentation/docs/configuration.md | 2 +- src/com/sap/piper/ConfigurationHelper.groovy | 3 +++ .../com/sap/piper/ConfigurationHelperTest.groovy | 12 +++++++++++- vars/artifactSetVersion.groovy | 3 +-- vars/checkChangeInDevelopment.groovy | 3 +++ vars/cloudFoundryDeploy.groovy | 2 ++ vars/newmanExecute.groovy | 5 ++++- vars/pipelineStashFilesAfterBuild.groovy | 2 ++ vars/pipelineStashFilesBeforeBuild.groovy | 2 ++ vars/setupCommonPipelineEnvironment.groovy | 6 +++--- vars/snykExecute.groovy | 2 ++ vars/testsPublishResults.groovy | 5 ++++- vars/transportRequestCreate.groovy | 3 +++ vars/transportRequestRelease.groovy | 3 +++ vars/transportRequestUploadFile.groovy | 3 +++ 15 files changed, 47 insertions(+), 9 deletions(-) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 962250092..1313d22a7 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -32,7 +32,7 @@ Following data (non-personal) is collected for example: **We store the telemetry data for not longer than 6 months on premises of SAP SE.** !!! note "Disable collection of telemetry data" - If you do not want to send telemetry data which helps this open source project to improve you can easily deactivate this. + If you do not want to send telemetry data you can easily deactivate this. This is done with either of the following two ways: diff --git a/src/com/sap/piper/ConfigurationHelper.groovy b/src/com/sap/piper/ConfigurationHelper.groovy index d69ed88f8..56ec6f195 100644 --- a/src/com/sap/piper/ConfigurationHelper.groovy +++ b/src/com/sap/piper/ConfigurationHelper.groovy @@ -46,6 +46,9 @@ class ConfigurationHelper implements Serializable { if (parameters.size() > 0 && compatibleParameters.size() > 0) { parameters = ConfigurationMerger.merge(handleCompatibility(step, compatibleParameters, parameters), null, parameters) } + if (filter) { + filter.add('collectTelemetryData') + } config = ConfigurationMerger.merge(parameters, filter, config) return this } diff --git a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy index fe3942ed2..4d82d55b0 100644 --- a/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy +++ b/test/groovy/com/sap/piper/ConfigurationHelperTest.groovy @@ -259,7 +259,7 @@ class ConfigurationHelperTest { public void testWithMandoryWithTrueConditionMissingValue() { thrown.expect(IllegalArgumentException) thrown.expectMessage('ERROR - NO VALUE AVAILABLE FOR missingKey') - + new ConfigurationHelper([verify: true]) .withMandatoryProperty('missingKey', null, { c -> return c.get('verify') }) } @@ -269,4 +269,14 @@ class ConfigurationHelperTest { new ConfigurationHelper([existingKey: 'anyValue', verify: true]) .withMandatoryProperty('existingKey', null, { c -> return c.get('verify') }) } + + @Test + public void testTelemetryConfigurationAvailable() { + Set filter = ['test'] + def configuration = new ConfigurationHelper([test: 'testValue']) + .mixin([collectTelemetryData: false], filter) + .use() + + Assert.assertThat(configuration, hasEntry('collectTelemetryData', false)) + } } diff --git a/vars/artifactSetVersion.groovy b/vars/artifactSetVersion.groovy index 4b46a536d..03ee4590f 100644 --- a/vars/artifactSetVersion.groovy +++ b/vars/artifactSetVersion.groovy @@ -7,7 +7,6 @@ import groovy.transform.Field import groovy.text.SimpleTemplateEngine @Field String STEP_NAME = 'artifactSetVersion' -@Field Set GENERAL_CONFIG_KEYS = ['collectTelemetryData'] @Field Map CONFIG_KEY_COMPATIBILITY = [gitSshKeyCredentialsId: 'gitCredentialsId'] @Field Set STEP_CONFIG_KEYS = [ 'artifactType', @@ -60,7 +59,7 @@ def call(Map parameters = [:], Closure body = null) { .withMandatoryProperty('gitSshUrl') .use() - new Utils().pushToSWA([step: STEP_NAME, stepParam1: config.buildTool], config) + new Utils().pushToSWA([step: STEP_NAME, stepParam1: config.buildTool, stepParam2: config.artifactType], config) def artifactVersioning = ArtifactVersioning.getArtifactVersioning(config.buildTool, script, config) def currentVersion = artifactVersioning.getVersion() diff --git a/vars/checkChangeInDevelopment.groovy b/vars/checkChangeInDevelopment.groovy index ca94d8bd3..2cd6e95d9 100644 --- a/vars/checkChangeInDevelopment.groovy +++ b/vars/checkChangeInDevelopment.groovy @@ -1,4 +1,5 @@ import com.sap.piper.GitUtils +import com.sap.piper.Utils import groovy.transform.Field import hudson.AbortException @@ -48,6 +49,8 @@ def call(parameters = [:]) { Map configuration = configHelper.use() + new Utils().pushToSWA([step: STEP_NAME], configuration) + def changeId = configuration.changeDocumentId if(changeId?.trim()) { diff --git a/vars/cloudFoundryDeploy.groovy b/vars/cloudFoundryDeploy.groovy index 8741061ed..e6a9eb6ad 100644 --- a/vars/cloudFoundryDeploy.groovy +++ b/vars/cloudFoundryDeploy.groovy @@ -46,6 +46,8 @@ def call(Map parameters = [:]) { .withMandatoryProperty('cloudFoundry/credentialsId') .use() + utils.pushToSWA([step: STEP_NAME, stepParam1: config.deployTool, stepParam2: config.deployType], config) + echo "[${STEP_NAME}] General parameters: deployTool=${config.deployTool}, deployType=${config.deployType}, cfApiEndpoint=${config.cloudFoundry.apiEndpoint}, cfOrg=${config.cloudFoundry.org}, cfSpace=${config.cloudFoundry.space}, cfCredentialsId=${config.cloudFoundry.credentialsId}, deployUser=${config.deployUser}" utils.unstash 'deployDescriptor' diff --git a/vars/newmanExecute.groovy b/vars/newmanExecute.groovy index 876622641..2f158fa46 100644 --- a/vars/newmanExecute.groovy +++ b/vars/newmanExecute.groovy @@ -1,5 +1,5 @@ import com.sap.piper.ConfigurationHelper - +import com.sap.piper.Utils import groovy.transform.Field import groovy.text.SimpleTemplateEngine @@ -22,11 +22,14 @@ def call(Map parameters = [:]) { // load default & individual configuration Map config = ConfigurationHelper .loadStepDefaults(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS) .mixin(parameters, PARAMETER_KEYS) .use() + new Utils().pushToSWA([step: STEP_NAME], config) + List collectionList = findFiles(glob: config.newmanCollection)?.toList() if (!config.dockerImage.isEmpty()) { diff --git a/vars/pipelineStashFilesAfterBuild.groovy b/vars/pipelineStashFilesAfterBuild.groovy index 3d958425f..3b3fb0abf 100644 --- a/vars/pipelineStashFilesAfterBuild.groovy +++ b/vars/pipelineStashFilesAfterBuild.groovy @@ -31,6 +31,8 @@ def call(Map parameters = [:]) { .mixin(parameters, PARAMETER_KEYS) .use() + new Utils().pushToSWA([step: STEP_NAME], config) + // store files to be checked with checkmarx if (config.runCheckmarx) { utils.stash( diff --git a/vars/pipelineStashFilesBeforeBuild.groovy b/vars/pipelineStashFilesBeforeBuild.groovy index 521fc55cb..ee14b011d 100644 --- a/vars/pipelineStashFilesBeforeBuild.groovy +++ b/vars/pipelineStashFilesBeforeBuild.groovy @@ -30,6 +30,8 @@ def call(Map parameters = [:]) { .mixin(parameters, PARAMETER_KEYS) .use() + new Utils().pushToSWA([step: STEP_NAME], config) + if (config.runOpaTests){ utils.stash('opa5', config.stashIncludes?.get('opa5')?config.stashIncludes.opa5:'**/*.*', config.stashExcludes?.get('opa5')?config.stashExcludes.opa5:'') } diff --git a/vars/setupCommonPipelineEnvironment.groovy b/vars/setupCommonPipelineEnvironment.groovy index b0743f676..79e55be3f 100644 --- a/vars/setupCommonPipelineEnvironment.groovy +++ b/vars/setupCommonPipelineEnvironment.groovy @@ -2,12 +2,12 @@ import com.sap.piper.ConfigurationHelper import com.sap.piper.Utils import groovy.transform.Field -@Field String STEP_NAME = 'setupPipelineEnvironment' +@Field String STEP_NAME = 'setupCommonPipelineEnvironment' @Field Set GENERAL_CONFIG_KEYS = ['collectTelemetryData'] def call(Map parameters = [:]) { - handlePipelineStepErrors (stepName: 'setupCommonPipelineEnvironment', stepParameters: parameters) { + handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) { def script = parameters.script @@ -22,7 +22,7 @@ def call(Map parameters = [:]) { .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS) .use() - new Utils().pushToSWA([step: STEP_NAME], config) + new Utils().pushToSWA([step: STEP_NAME, stepParam4: parameters.customDefaults?'true':'false'], config) } } diff --git a/vars/snykExecute.groovy b/vars/snykExecute.groovy index 6478b2e77..507a8de15 100644 --- a/vars/snykExecute.groovy +++ b/vars/snykExecute.groovy @@ -35,6 +35,8 @@ def call(Map parameters = [:]) { .withMandatoryProperty('snykCredentialsId') .use() + new Utils().pushToSWA([step: STEP_NAME], config) + utils.unstashAll(config.stashContent) switch(config.scanType) { diff --git a/vars/testsPublishResults.groovy b/vars/testsPublishResults.groovy index a57b36cfa..92fbca72d 100644 --- a/vars/testsPublishResults.groovy +++ b/vars/testsPublishResults.groovy @@ -3,7 +3,7 @@ import com.cloudbees.groovy.cps.NonCPS import com.sap.piper.ConfigurationHelper import com.sap.piper.ConfigurationMerger import com.sap.piper.MapUtils - +import com.sap.piper.Utils import groovy.transform.Field @Field List TOOLS = [ @@ -30,10 +30,13 @@ def call(Map parameters = [:]) { // load default & individual configuration Map configuration = ConfigurationHelper .loadStepDefaults(this) + .mixinGeneralConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS) .mixin(parameters, PARAMETER_KEYS) .use() + new Utils().pushToSWA([step: STEP_NAME], configuration) + // UNIT TESTS publishJUnitReport(configuration.get('junit')) // CODE COVERAGE diff --git a/vars/transportRequestCreate.groovy b/vars/transportRequestCreate.groovy index c675f8706..6d87fc75a 100644 --- a/vars/transportRequestCreate.groovy +++ b/vars/transportRequestCreate.groovy @@ -1,4 +1,5 @@ import com.sap.piper.GitUtils +import com.sap.piper.Utils import groovy.transform.Field import com.sap.piper.ConfigurationHelper @@ -44,6 +45,8 @@ def call(parameters = [:]) { Map configuration = configHelper.use() + new Utils().pushToSWA([step: STEP_NAME], configuration) + def changeDocumentId = configuration.changeDocumentId if(changeDocumentId?.trim()) { diff --git a/vars/transportRequestRelease.groovy b/vars/transportRequestRelease.groovy index 81ea5cdc7..896b5a645 100644 --- a/vars/transportRequestRelease.groovy +++ b/vars/transportRequestRelease.groovy @@ -1,4 +1,5 @@ import com.sap.piper.GitUtils +import com.sap.piper.Utils import groovy.transform.Field import com.sap.piper.ConfigurationHelper @@ -45,6 +46,8 @@ def call(parameters = [:]) { Map configuration = configHelper.use() + new Utils().pushToSWA([step: STEP_NAME], configuration) + def transportRequestId = configuration.transportRequestId if(transportRequestId?.trim()) { diff --git a/vars/transportRequestUploadFile.groovy b/vars/transportRequestUploadFile.groovy index edc6108aa..aa26764c6 100644 --- a/vars/transportRequestUploadFile.groovy +++ b/vars/transportRequestUploadFile.groovy @@ -1,4 +1,5 @@ import com.sap.piper.GitUtils +import com.sap.piper.Utils import groovy.transform.Field import com.sap.piper.ConfigurationHelper @@ -50,6 +51,8 @@ def call(parameters = [:]) { Map configuration = configHelper.use() + new Utils().pushToSWA([step: STEP_NAME], configuration) + def changeDocumentId = configuration.changeDocumentId if(changeDocumentId?.trim()) {