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,