From 3b2e42c74f980ebc74a27ebe0982a1744656200b Mon Sep 17 00:00:00 2001
From: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com>
Date: Thu, 31 Jan 2019 09:39:13 +0100
Subject: [PATCH] Add step containerExecuteStructureTest (#441)
* add step containerExecuteStructureTest
* include PR-review feedback
* documentation
---
.../steps/containerExecuteStructureTests.md | 82 ++++++++++
documentation/mkdocs.yml | 1 +
resources/default_pipeline_environment.yml | 11 +-
.../ContainerExecuteStructureTestsTest.groovy | 150 ++++++++++++++++++
test/groovy/util/BasePiperTestContext.groovy | 1 +
vars/containerExecuteStructureTests.groovy | 132 +++++++++++++++
vars/dockerExecuteOnKubernetes.groovy | 10 +-
7 files changed, 383 insertions(+), 4 deletions(-)
create mode 100644 documentation/docs/steps/containerExecuteStructureTests.md
create mode 100644 test/groovy/ContainerExecuteStructureTestsTest.groovy
create mode 100644 vars/containerExecuteStructureTests.groovy
diff --git a/documentation/docs/steps/containerExecuteStructureTests.md b/documentation/docs/steps/containerExecuteStructureTests.md
new file mode 100644
index 000000000..7d1ec3fde
--- /dev/null
+++ b/documentation/docs/steps/containerExecuteStructureTests.md
@@ -0,0 +1,82 @@
+# containerExecuteStructureTests
+
+## Description
+
+In this step [Container Structure Tests](https://github.com/GoogleContainerTools/container-structure-test) are executed.
+
+This testing framework allows you to execute different test types against a Docker container, for example:
+
+* Command tests (only if a Docker Deamon is available)
+* File existence tests
+* File content tests
+* Metadata test
+
+## Prerequisites
+
+Test configuration is available.
+
+## Example
+
+```
+containerExecuteStructureTests(
+ script: this,
+ testConfiguration: 'config.yml',
+ testImage: 'node:latest'
+)
+```
+
+## Parameters
+
+| parameter | mandatory | default | possible values |
+| ----------|-----------|---------|-----------------|
+|script|yes|||
+|containerCommand|no|``||
+|containerShell|no|``||
+|dockerImage|yes|`ppiper/container-structure-test`||
+|dockerOptions|no|`-u 0 --entrypoint=''`||
+|failOnError|no|`true`|`true`, `false`|
+|pullImage|no||`true`, `false`|
+|stashContent|no|
||
+|testConfiguration|no|||
+|testDriver|no|||
+|testImage|no|||
+|testReportFilePath|no|`cst-report.json`||
+|verbose|no||`true`, `false`|
+
+Details:
+
+* `script` defines the global script environment of the Jenkinsfile run. Typically `this` is passed to this parameter. This allows the function to access the [`commonPipelineEnvironment`](commonPipelineEnvironment.md) for storing the measured duration.
+* `containerCommand`: Only for Kubernetes environments: Command which is executed to keep container alive, defaults to '/usr/bin/tail -f /dev/null'
+* containerShell: Only for Kubernetes environments: Shell to be used inside container, defaults to '/bin/sh'
+* dockerImage: Docker image for code execution.
+* dockerOptions: Options to be passed to Docker image when starting it (only relevant for non-Kubernetes case).
+* failOnError: Defines the behavior, in case tests fail.
+* pullImage: Only relevant for testDriver 'docker'.
+* stashContent: If specific stashes should be considered for the tests, you can pass this via this parameter.
+* testConfiguration: Container structure test configuration in yml or json format. You can pass a pattern in order to execute multiple tests.
+* testDriver: Container structure test driver to be used for testing, please see [https://github.com/GoogleContainerTools/container-structure-test](https://github.com/GoogleContainerTools/container-structure-test) for details.
+* testImage: Image to be tested
+* testReportFilePath: Path and name of the test report which will be generated
+* verbose: Print more detailed information into the log.
+
+## Step configuration
+
+We recommend to define values of step parameters via [config.yml file](../configuration.md).
+
+In following sections the configuration is possible:
+
+| parameter | general | step | stage |
+| ----------|-----------|---------|-----------------|
+|script||||
+|containerCommand||X|X|
+|containerShell||X|X|
+|dockerImage||X|X|
+|dockerOptions||X|X|
+|failOnError||X|X|
+|pullImage||X|X|
+|stashContent||X|X|
+|testConfiguration||X|X|
+|testDriver||X|X|
+|testImage||X|X|
+|testReportFilePath||X|X|
+|verbose|X|X|X|
diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml
index 9af21a86b..b9f555ff5 100644
--- a/documentation/mkdocs.yml
+++ b/documentation/mkdocs.yml
@@ -9,6 +9,7 @@ nav:
- checksPublishResults: steps/checksPublishResults.md
- cloudFoundryDeploy: steps/cloudFoundryDeploy.md
- commonPipelineEnvironment: steps/commonPipelineEnvironment.md
+ - containerExecuteStructureTests: steps/containerExecuteStructureTests.md
- dockerExecute: steps/dockerExecute.md
- dockerExecuteOnKubernetes: steps/dockerExecuteOnKubernetes.md
- durationMeasure: steps/durationMeasure.md
diff --git a/resources/default_pipeline_environment.yml b/resources/default_pipeline_environment.yml
index d114564fe..f3b7b26ce 100644
--- a/resources/default_pipeline_environment.yml
+++ b/resources/default_pipeline_environment.yml
@@ -143,6 +143,15 @@ steps:
mtaDeployPlugin:
dockerImage: 's4sdk/docker-cf-cli'
dockerWorkspace: '/home/piper'
+ containerExecuteStructureTests:
+ containerCommand: '/busybox/tail -f /dev/null'
+ containerShell: '/busybox/sh'
+ dockerImage: 'ppiper/container-structure-test'
+ dockerOptions: "-u 0 --entrypoint=''"
+ failOnError: true
+ stashContent:
+ - 'tests'
+ testReportFilePath: 'cst-report.json'
dockerExecute:
stashContent: []
dockerExecuteOnKubernetes:
@@ -261,7 +270,7 @@ steps:
opensourceConfiguration: '**/srcclr.yml, **/vulas-custom.properties, **/.nsprc, **/.retireignore, **/.retireignore.json, **/.snyk'
pipelineConfigAndTests: '.pipeline/**'
securityDescriptor: '**/xs-security.json'
- tests: '**/pom.xml, **/*.json, **/*.xml, **/src/**, **/node_modules/**, **/specs/**, **/env/**, **/*.js'
+ tests: '**/pom.xml, **/*.json, **/*.xml, **/src/**, **/node_modules/**, **/specs/**, **/env/**, **/*.js, **/tests/**'
stashExcludes:
buildDescriptor: '**/node_modules/**/package.json'
deployDescriptor: ''
diff --git a/test/groovy/ContainerExecuteStructureTestsTest.groovy b/test/groovy/ContainerExecuteStructureTestsTest.groovy
new file mode 100644
index 000000000..f45133c62
--- /dev/null
+++ b/test/groovy/ContainerExecuteStructureTestsTest.groovy
@@ -0,0 +1,150 @@
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.ExpectedException
+import org.junit.rules.RuleChain
+import util.*
+
+import static org.hamcrest.Matchers.*
+import static org.junit.Assert.assertThat
+
+class ContainerExecuteStructureTestsTest 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)
+ private JenkinsDockerExecuteRule jedr = new JenkinsDockerExecuteRule(this)
+
+ @Rule
+ public RuleChain rules = Rules
+ .getCommonRules(this)
+ .around(new JenkinsReadYamlRule(this))
+ .around(thrown)
+ .around(jedr)
+ .around(jscr)
+ .around(jlr)
+ .around(jsr) // needs to be activated after jedr, otherwise executeDocker is not mocked
+
+ @Before
+ void init() throws Exception {
+ helper.registerAllowedMethod('stash', [String.class], null)
+ helper.registerAllowedMethod("findFiles", [Map.class], { map ->
+ def files
+ if(map.glob == 'notFound.json')
+ files = []
+ else if(map.glob == 'cst/*.yml')
+ files = [
+ new File("cst/test1.yml"),
+ new File("cst/test2.yml")
+ ]
+ else
+ files = [new File(map.glob)]
+ return files.toArray()
+ })
+ }
+
+ @Test
+ void testExecuteContainterStructureTestsDefault() throws Exception {
+ helper.registerAllowedMethod('readFile', [String.class], {s ->
+ return '{testResult: true}'
+ })
+ jsr.step.containerExecuteStructureTests(
+ script: nullScript,
+ juStabUtils: utils,
+ testConfiguration: 'cst/*.yml',
+ testImage: 'myRegistry/myImage:myTag'
+ )
+ // asserts
+ assertThat(jscr.shell, hasItem(allOf(
+ stringContainsInOrder(['#!/busybox/sh', 'container-structure-test', '--config']),
+ containsString("--config cst${File.separator}test1.yml"),
+ containsString("--config cst${File.separator}test2.yml"),
+ containsString('--driver docker'),
+ containsString('--image myRegistry/myImage:myTag'),
+ containsString('--test-report cst-report.json'),
+ )))
+ //currently no default Docker image
+ assertThat(jedr.dockerParams.dockerImage, is('ppiper/container-structure-test'))
+ assertThat(jedr.dockerParams.dockerOptions, is("-u 0 --entrypoint=''"))
+ assertThat(jedr.dockerParams.containerCommand, is('/busybox/tail -f /dev/null'))
+ assertThat(jedr.dockerParams.containerShell, is('/busybox/sh'))
+ assertThat(jlr.log, containsString('{testResult: true}'))
+ assertThat(jscr.shell, hasItem('docker pull myRegistry/myImage:myTag'))
+ }
+
+ @Test
+ void testExecuteContainterStructureTestsK8S() throws Exception {
+ def envDefault = nullScript.env
+ nullScript.env = [ON_K8S: 'true']
+ jsr.step.containerExecuteStructureTests(
+ script: nullScript,
+ juStabUtils: utils,
+ containerCommand: '/busybox/tail -f /dev/null',
+ containerShell: '/bin/sh',
+ dockerImage: 'myRegistry:55555/pathTo/myImage:myTag',
+ testConfiguration: 'cst/*.yml',
+ testImage: 'myRegistry/myImage:myTag'
+ )
+ nullScript.env = envDefault
+ // asserts
+ assertThat(jscr.shell, hasItem(allOf(
+ stringContainsInOrder(['#!/bin/sh', 'container-structure-test', '--config']),
+ containsString("--config cst${File.separator}test1.yml"),
+ containsString("--config cst${File.separator}test2.yml"),
+ containsString('--driver tar'),
+ containsString('--image myRegistry/myImage:myTag'),
+ containsString('--test-report cst-report.json'),
+ )))
+ assertThat(jedr.dockerParams.dockerImage, is('myRegistry:55555/pathTo/myImage:myTag'))
+ assertThat(jedr.dockerParams.containerCommand, is('/busybox/tail -f /dev/null'))
+ assertThat(jscr.shell, not(hasItem('docker pull myRegistry/myImage:myTag')))
+ }
+
+ @Test
+ void testExecuteContainterStructureTestsError() throws Exception {
+ helper.registerAllowedMethod('readFile', [String.class], {s ->
+ return '{testResult: true}'
+ })
+ helper.registerAllowedMethod('sh', [String.class], {s ->
+ if (s.startsWith('#!/busybox/sh\ncontainer-structure-test test')) {
+ throw new GroovyRuntimeException('shell call failed')
+ }
+ return null
+ })
+ thrown.expectMessage('shell call failed')
+
+ jsr.step.containerExecuteStructureTests(
+ script: nullScript,
+ juStabUtils: utils,
+ containerCommand: '/busybox/tail -f /dev/null',
+ containerShell: '/busybox/sh',
+ testConfiguration: 'cst/*.yml',
+ testImage: 'myRegistry/myImage:myTag'
+ )
+ }
+
+ @Test
+ void testExecuteContainterStructureTestsErrorNoFailure() throws Exception {
+ helper.registerAllowedMethod('readFile', [String.class], {s ->
+ return '{testResult: true}'
+ })
+ helper.registerAllowedMethod('sh', [String.class], {s ->
+ if (s.startsWith('#!/busybox/sh\ncontainer-structure-test test')) {
+ throw new GroovyRuntimeException('shell call failed')
+ }
+ return null
+ })
+
+ jsr.step.containerExecuteStructureTests(
+ script: nullScript,
+ juStabUtils: utils,
+ containerCommand: '/busybox/tail -f /dev/null',
+ containerShell: '/busybox/sh',
+ failOnError: false,
+ testConfiguration: 'cst/*.yml',
+ testImage: 'myRegistry/myImage:myTag'
+ )
+
+ assertThat(jlr.log, containsString('Test execution failed'))
+ }
+}
diff --git a/test/groovy/util/BasePiperTestContext.groovy b/test/groovy/util/BasePiperTestContext.groovy
index 16f430aee..f27a840b2 100644
--- a/test/groovy/util/BasePiperTestContext.groovy
+++ b/test/groovy/util/BasePiperTestContext.groovy
@@ -16,6 +16,7 @@ class BasePiperTestContext {
Script nullScript() {
def nullScript = InvokerHelper.createScript(null, new Binding())
nullScript.currentBuild = [:]
+ nullScript.env = [:]
LibraryLoadingTestExecutionListener.prepareObjectInterceptors(nullScript)
return nullScript
}
diff --git a/vars/containerExecuteStructureTests.groovy b/vars/containerExecuteStructureTests.groovy
new file mode 100644
index 000000000..045ada3fd
--- /dev/null
+++ b/vars/containerExecuteStructureTests.groovy
@@ -0,0 +1,132 @@
+import static com.sap.piper.Prerequisites.checkScript
+
+import com.sap.piper.ConfigurationHelper
+import com.sap.piper.Utils
+import groovy.transform.Field
+
+@Field String STEP_NAME = getClass().getName()
+
+@Field Set GENERAL_CONFIG_KEYS = [
+ /**
+ * Print more detailed information into the log.
+ * @possibleValues `true`, `false`
+ */
+ 'verbose'
+
+]
+
+@Field Set STEP_CONFIG_KEYS = GENERAL_CONFIG_KEYS.plus([
+ /**
+ * Only for Kubernetes environments: Command which is executed to keep container alive, defaults to '/usr/bin/tail -f /dev/null'
+ */
+ 'containerCommand',
+ /**
+ * Only for Kubernetes environments: Shell to be used inside container, defaults to '/bin/sh'
+ */
+ 'containerShell',
+ /**
+ * Docker image for code execution.
+ */
+ 'dockerImage',
+ /**
+ * Options to be passed to Docker image when starting it (only relevant for non-Kubernetes case).
+ */
+ 'dockerOptions',
+ /**
+ * Defines the behavior, in case tests fail.
+ * @possibleValues `true`, `false`
+ */
+ 'failOnError',
+ /**
+ * Only relevant for testDriver 'docker'.
+ * @possibleValues `true`, `false`
+ */
+ 'pullImage',
+ /**
+ * If specific stashes should be considered for the tests, you can pass this via this parameter.
+ */
+ 'stashContent',
+ /**
+ * Container structure test configuration in yml or json format. You can pass a pattern in order to execute multiple tests.
+ */
+ 'testConfiguration',
+ /**
+ * Container structure test driver to be used for testing, please see https://github.com/GoogleContainerTools/container-structure-test for details.
+ */
+ 'testDriver',
+ /**
+ * Image to be tested
+ */
+ 'testImage',
+ /**
+ * Path and name of the test report which will be generated
+ */
+ 'testReportFilePath',
+])
+
+@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS
+
+void call(Map parameters = [:]) {
+ handlePipelineStepErrors(stepName: STEP_NAME, stepParameters: parameters) {
+
+ def script = checkScript(this, parameters) ?: this
+
+ def utils = parameters?.juStabUtils ?: new Utils()
+
+ // load default & individual configuration
+ Map config = ConfigurationHelper.newInstance(this)
+ .loadStepDefaults()
+ .mixinGeneralConfig(script.commonPipelineEnvironment, GENERAL_CONFIG_KEYS)
+ .mixinStepConfig(script.commonPipelineEnvironment, STEP_CONFIG_KEYS)
+ .mixinStageConfig(script.commonPipelineEnvironment, parameters.stageName?:env.STAGE_NAME, STEP_CONFIG_KEYS)
+ .mixin(parameters, PARAMETER_KEYS)
+ .addIfEmpty('testDriver', Boolean.valueOf(script.env.ON_K8S) ? 'tar' : 'docker')
+ .addIfNull('pullImage', !Boolean.valueOf(script.env.ON_K8S))
+ .withMandatoryProperty('dockerImage')
+ .use()
+
+ utils.pushToSWA([step: STEP_NAME], config)
+
+ config.stashContent = utils.unstashAll(config.stashContent)
+
+ List testConfig = findFiles(glob: config.testConfiguration)?.toList()
+ if (testConfig.isEmpty()) {
+ error "[${STEP_NAME}] No test description found with pattern '${config.testConfiguration}'"
+ } else {
+ echo "[${STEP_NAME}] Found files ${testConfig}"
+ }
+
+ def testConfigArgs = ''
+ testConfig.each {conf ->
+ testConfigArgs += "--config ${conf} "
+ }
+
+ //workaround for non-working '--pull' option in version 1.7.0 of container-structure-tests, see https://github.com/GoogleContainerTools/container-structure-test/issues/193
+ if (config.pullImage) {
+ if (config.verbose) echo "[${STEP_NAME}] Pulling image since configuration option pullImage is set to '${config.pullImage}'"
+ sh "docker pull ${config.testImage}"
+ }
+
+ try {
+ dockerExecute(
+ script: script,
+ containerCommand: config.containerCommand,
+ containerShell: config.containerShell,
+ dockerImage: config.dockerImage,
+ dockerOptions: config.dockerOptions,
+ stashContent: config.stashContent
+ ) {
+ sh """#!${config.containerShell?:'/bin/sh'}
+container-structure-test test ${testConfigArgs} --driver ${config.testDriver} --image ${config.testImage} --test-report ${config.testReportFilePath}${config.verbose ? ' --verbosity debug' : ''}"""
+ }
+ } catch (err) {
+ echo "[${STEP_NAME}] Test execution failed"
+ script.currentBuild.result = 'UNSTABLE'
+ if (config.failOnError) throw err
+ } finally {
+ echo "${readFile(config.testReportFilePath)}"
+ archiveArtifacts artifacts: config.testReportFilePath, allowEmptyArchive: true
+ }
+
+ }
+}
diff --git a/vars/dockerExecuteOnKubernetes.groovy b/vars/dockerExecuteOnKubernetes.groovy
index c5f0fd66f..52d29b78f 100644
--- a/vars/dockerExecuteOnKubernetes.groovy
+++ b/vars/dockerExecuteOnKubernetes.groovy
@@ -84,12 +84,13 @@ void executeOnPod(Map config, utils, Closure body) {
if (config.containerShell) {
containerParams.shell = config.containerShell
}
+ echo "ContainerConfig: ${containerParams}"
container(containerParams){
try {
utils.unstashAll(config.stashContent)
body()
} finally {
- stashWorkspace(config, 'container')
+ stashWorkspace(config, 'container', true)
}
}
} else {
@@ -103,11 +104,14 @@ void executeOnPod(Map config, utils, Closure body) {
}
}
-private String stashWorkspace(config, prefix) {
+private String stashWorkspace(config, prefix, boolean chown = false) {
def stashName = "${prefix}-${config.uniqueId}"
try {
// Every dockerImage used in the dockerExecuteOnKubernetes should have user id 1000
- sh "chown -R 1000:1000 ."
+ if (chown) {
+ sh """#!${config.containerShell?:'/bin/sh'}
+chown -R 1000:1000 ."""
+ }
stash(
name: stashName,
includes: config.stashIncludes.workspace,