Skip to content

Commit

Permalink
Merge bcbb4d2 into 0e5ccab
Browse files Browse the repository at this point in the history
  • Loading branch information
OliverNocon committed Oct 18, 2018
2 parents 0e5ccab + bcbb4d2 commit c892ec6
Show file tree
Hide file tree
Showing 9 changed files with 325 additions and 0 deletions.
75 changes: 75 additions & 0 deletions documentation/docs/steps/pipelineRestartSteps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# pipelineRestartSteps

## Description
Support of restarting failed stages or steps in a pipeline is limited in Jenkins.

This has been documented in the [Jenkins Jira issue JENKINS-33846](https://issues.jenkins-ci.org/browse/JENKINS-33846).

For declarative pipelines there is a solution available which partially addresses this topic:
https://jenkins.io/doc/book/pipeline/running-pipelines/#restart-from-a-stage.

Nonetheless, still features are missing, so it can't be used in all cases.
The more complex Piper pipelines which share a state via [`commonPipelineEnvironment`](commonPipelineEnvironment.md) will for example not work with the standard _restart-from-stage_.

The step `pipelineRestartSteps` aims to address this gap and allows individual parts of a pipeline (e.g. a failed deployment) to be restarted.

This is done in a way that the pipeline waits for user input to restart the pipeline in case of a failure. In case this user input is not provided the pipeline stops after a timeout which can be configured.

## Prerequisites
none


## Example

Usage of pipeline step:

```groovy
pipelineRestartSteps (script: this) {
node {
//your steps ...
}
}
```

!!! caution
Use `node` inside the step. If a `node` exists outside the step context, the `input` step which is triggered in the process will block a Jenkins executor.

In case you cannot use `node` inside this step, please choose the parameter `timeoutInSeconds` carefully!


## Parameters

| parameter | mandatory | default | possible values |
| ----------|-----------|---------|-----------------|
|script|yes|||
|sendMail|no|`true`||
|timeoutInSeconds|no|`900`||

### 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.
* If `sendMail: true` the step `mailSendNotification` will be triggered in case of an error
* `timeoutInSeconds` defines the time period where the job waits for input. Default is 15 minutes. Once this time is passed the job enters state FAILED.


## 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||||
|sendMail|X|X|X|
|timeoutInSeconds|X|X|X|

## Return value
none

## Side effects
none

## Exceptions
none

1 change: 1 addition & 0 deletions documentation/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ nav:
- mtaBuild: steps/mtaBuild.md
- neoDeploy: steps/neoDeploy.md
- pipelineExecute: steps/pipelineExecute.md
- pipelineRestartSteps: steps/pipelineRestartSteps.md
- pipelineStashFiles: steps/pipelineStashFiles.md
- prepareDefaultValues: steps/prepareDefaultValues.md
- seleniumExecuteTests: steps/seleniumExecuteTests.md
Expand Down
3 changes: 3 additions & 0 deletions resources/default_pipeline_environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ steps:
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'
pipelineRestartSteps:
sendMail: true
timeoutInSeconds: 900
pipelineStashFilesAfterBuild:
runOpaTests: false
stashIncludes:
Expand Down
11 changes: 11 additions & 0 deletions src/com/sap/piper/JenkinsUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@ package com.sap.piper

import com.cloudbees.groovy.cps.NonCPS
import jenkins.model.Jenkins
import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException

@NonCPS
static def isPluginActive(pluginId) {
return Jenkins.instance.pluginManager.plugins.find { p -> p.isActive() && p.getShortName() == pluginId }
}

def nodeAvailable() {
try {
sh "echo 'Node is available!'"
} catch (MissingContextVariableException e) {
echo "No node context available."
return false
}
return true
}
126 changes: 126 additions & 0 deletions test/groovy/PipelineRestartStepsTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!groovy
import org.jenkinsci.plugins.workflow.steps.FlowInterruptedException
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import util.*

import static org.hamcrest.CoreMatchers.containsString
import static org.hamcrest.CoreMatchers.is
import static org.junit.Assert.assertThat

class PipelineRestartStepsTest extends BasePiperTest {

private JenkinsErrorRule jer = new JenkinsErrorRule(this)
private JenkinsLoggingRule jlr = new JenkinsLoggingRule(this)
private JenkinsStepRule jsr = new JenkinsStepRule(this)

@Rule
public RuleChain chain = Rules.getCommonRules(this)
.around(new JenkinsReadYamlRule(this))
.around(jer)
.around(jlr)
.around(jsr)

@Test
void testError() throws Exception {

def mailBuildResult = ''
helper.registerAllowedMethod('mailSendNotification', [Map.class], { m ->
mailBuildResult = m.buildResult
return null
})

helper.registerAllowedMethod('timeout', [Map.class, Closure.class], { m, closure ->
assertThat(m.time, is(1))
assertThat(m.unit, is('SECONDS'))
return closure()
})

def iterations = 0
helper.registerAllowedMethod('input', [Map.class], { m ->
iterations ++
assertThat(m.message, is('Do you want to restart?'))
assertThat(m.ok, is('Restart'))
if (iterations > 1) {
throw new FlowInterruptedException()
} else {
return null
}
})

try {
jsr.step.pipelineRestartSteps ([
script: nullScript,
jenkinsUtilsStub: jenkinsUtils,
sendMail: true,
timeoutInSeconds: 1

]) {
throw new hudson.AbortException('I just created an error')
}
} catch(err) {
assertThat(jlr.log, containsString('ERROR occured: hudson.AbortException: I just created an error'))
assertThat(mailBuildResult, is('UNSTABLE'))
}
}

@Test
void testErrorNoMail() throws Exception {

def mailBuildResult = ''
helper.registerAllowedMethod('mailSendNotification', [Map.class], { m ->
mailBuildResult = m.buildResult
return null
})

helper.registerAllowedMethod('timeout', [Map.class, Closure.class], { m, closure ->
assertThat(m.time, is(1))
assertThat(m.unit, is('SECONDS'))
return closure()
})

def iterations = 0
helper.registerAllowedMethod('input', [Map.class], { m ->
iterations ++
assertThat(m.message, is('Do you want to restart?'))
assertThat(m.ok, is('Restart'))
if (iterations > 1) {
throw new FlowInterruptedException()
} else {
return null
}
})

try {
jsr.step.pipelineRestartSteps ([
script: nullScript,
jenkinsUtilsStub: jenkinsUtils,
sendMail: false,
timeoutInSeconds: 1

]) {
throw new hudson.AbortException('I just created an error')
}
} catch(err) {
assertThat(jlr.log, containsString('ERROR occured: hudson.AbortException: I just created an error'))
assertThat(mailBuildResult, is(''))
}
}

@Test
void testSuccess() throws Exception {

jsr.step.pipelineRestartSteps ([
script: nullScript,
jenkinsUtilsStub: jenkinsUtils,
sendMail: false,
timeoutInSeconds: 1

]) {
nullScript.echo 'This is a test'
}

assertThat(jlr.log, containsString('This is a test'))
}
}
45 changes: 45 additions & 0 deletions test/groovy/com/sap/piper/JenkinsUtilsTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.sap.piper

import org.jenkinsci.plugins.workflow.steps.MissingContextVariableException
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExpectedException
import org.junit.rules.RuleChain
import util.BasePiperTest
import util.JenkinsLoggingRule
import util.JenkinsShellCallRule
import util.Rules

import static org.hamcrest.Matchers.*
import static org.junit.Assert.assertThat

class JenkinsUtilsTest extends BasePiperTest {
private JenkinsLoggingRule jlr = new JenkinsLoggingRule(this)
private JenkinsShellCallRule jscr = new JenkinsShellCallRule(this)

@Rule
public RuleChain rules = Rules
.getCommonRules(this)
.around(jscr)
.around(jlr)

@Test
void testNodeAvailable() {
def result = jenkinsUtils.nodeAvailable()
assertThat(jscr.shell, contains("echo 'Node is available!'"))
assertThat(result, is(true))
}

@Test
void testNoNodeAvailable() {
helper.registerAllowedMethod('sh', [String.class], {s ->
throw new MissingContextVariableException(String.class)
})

def result = jenkinsUtils.nodeAvailable()
assertThat(jlr.log, containsString('No node context available.'))
assertThat(result, is(false))
}

}
4 changes: 4 additions & 0 deletions test/groovy/util/BasePiperTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package util

import com.lesfurets.jenkins.unit.BasePipelineTest
import com.sap.piper.GitUtils
import com.sap.piper.JenkinsUtils
import com.sap.piper.Utils
import org.junit.Before
import org.junit.runner.RunWith
Expand All @@ -23,6 +24,9 @@ abstract class BasePiperTest extends BasePipelineTest {
@Autowired
Utils utils

@Autowired
JenkinsUtils jenkinsUtils

@Override
@Before
void setUp() throws Exception {
Expand Down
8 changes: 8 additions & 0 deletions test/groovy/util/BasePiperTestContext.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package util

import com.sap.piper.GitUtils
import com.sap.piper.JenkinsUtils
import com.sap.piper.Utils
import org.codehaus.groovy.runtime.InvokerHelper
import org.springframework.context.annotation.Bean
Expand Down Expand Up @@ -36,4 +37,11 @@ class BasePiperTestContext {
LibraryLoadingTestExecutionListener.prepareObjectInterceptors(mockUtils)
return mockUtils
}

@Bean
JenkinsUtils mockJenkinsUtils() {
def mockJenkinsUtils = new JenkinsUtils()
LibraryLoadingTestExecutionListener.prepareObjectInterceptors(mockJenkinsUtils)
return mockJenkinsUtils
}
}
52 changes: 52 additions & 0 deletions vars/pipelineRestartSteps.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import com.sap.piper.JenkinsUtils
import com.sap.piper.ConfigurationHelper
import groovy.transform.Field

@Field String STEP_NAME = 'pipelineRestartSteps'
@Field Set STEP_CONFIG_KEYS = [
'sendMail',
'timeoutInSeconds'
]
@Field Set PARAMETER_KEYS = STEP_CONFIG_KEYS

def call(Map parameters = [:], body) {
handlePipelineStepErrors (stepName: STEP_NAME, stepParameters: parameters) {
def script = parameters.script ?: [commonPipelineEnvironment: commonPipelineEnvironment]
def jenkinsUtils = parameters.jenkinsUtilsStub ?: new JenkinsUtils()
// 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()

def restart = true
while (restart) {
try {
body()
restart = false
} catch (Throwable err) {
echo "ERROR occured: ${err}"
if (config.sendMail)
if (jenkinsUtils.nodeAvailable()) {
mailSendNotification script: script, buildResult: 'UNSTABLE'
} else {
node {
mailSendNotification script: script, buildResult: 'UNSTABLE'
}
}

try {
timeout(time: config.timeoutInSeconds, unit: 'SECONDS') {
input message: 'Do you want to restart?', ok: 'Restart'
}
} catch(e) {
restart = false
throw err
}
}
}
}
}

0 comments on commit c892ec6

Please sign in to comment.