Skip to content

Commit

Permalink
Merge pull request #259 from jleopold28/plan_only
Browse files Browse the repository at this point in the history
Issue #162: Support "Plan only"
  • Loading branch information
kmanning committed Aug 1, 2020
2 parents 278dd81 + c9fd6bb commit eb814f9
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 59 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* [Issue #257](https://github.com/manheim/terraform-pipeline/issues/257) Fix codecov reporting
* [Issue #88](https://github.com/manheim/terraform-pipeline/issues/88) Add an optional TerraformDestroyStage
* [Issue #24](https://github.com/manheim/terraform-pipeline/issues/24) ConfirmApplyPlugin - allow customization
* [Issue #162](https://github.com/manheim/terraform-pipeline/issues/162) Support "plan only" on master

# v5.8

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ The example above gives you a bare-bones pipeline, and there may be Jenkinsfile
* [TerraformDirectoryPlugin](./docs/TerraformDirectoryPlugin.md): Change the default directory containing your terraform code.
* [TerraformLandscapePlugin](./docs/TerraformLandscapePlugin.md): Enable terraform-landscape plan output.
* [DestroyPlugin](./docs/DestroyPlugin.md): Use this to change the pipeline functionality to `terraform destroy`. (Requires manual confirmation)
* [PlanOnlyPlugin](./docs/PlanOnlyPlugin.md): Use this to change the pipeline functionality to `terraform plan` only.

## Write your own Plugin

Expand Down
24 changes: 24 additions & 0 deletions docs/PlanOnlyPlugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## [PlanOnlyPlugin](../src/PlanOnlyPlugin.groovy)

Enable this plugin to change pipeline functionality to `terraform plan`.

```
// Jenkinsfile
@Library(['terraform-pipeline@v3.10']) _
Jenkinsfile.init(this, env)
// This enables the "plan only" functionality
PlanOnlyPlugin.init()
def validate = new TerraformValidateStage()
def destroyQa = new TerraformEnvironmentStage('qa')
def destroyUat = new TerraformEnvironmentStage('uat')
def destroyProd = new TerraformEnvironmentStage('prod')
validate.then(destroyQa)
.then(destroyUat)
.then(destroyProd)
.build()
```
21 changes: 21 additions & 0 deletions src/Jenkinsfile.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Jenkinsfile {
public static instance = new Jenkinsfile()
public static declarative = false
public static pipelineTemplate
public static List params = []

def String getStandardizedRepoSlug() {
if (repoSlug != null) {
Expand Down Expand Up @@ -78,6 +79,13 @@ class Jenkinsfile {
}

public static void build(List<Stage> stages) {
// Decorate the first stage with the list of parameters
// The stage must have the decorate() method available
if (stages.size() > 0 && stages[0].metaClass.respondsTo(stages[0], 'decorate', String, Closure)) {
Stage first_stage = stages[0]
first_stage.decorate(TerraformEnvironmentStage.ALL, createParamClosure())
}

if (!declarative) {
stages.each { Stage stage -> stage.build() }
} else {
Expand All @@ -89,6 +97,14 @@ class Jenkinsfile {
}
}

private static Closure createParamClosure() {
return { ->
properties([
parameters(params)
])
}
}

public static getPipelineTemplate(List<Stage> stages) {
switch (stages.size()) {
case 2:
Expand Down Expand Up @@ -129,9 +145,14 @@ class Jenkinsfile {
this.instance = newInstance
}

public static addParam(newParam) {
params << newParam
}

public static reset() {
instance = new Jenkinsfile()
original = null
params = []
defaultNodeName = null
}
}
42 changes: 42 additions & 0 deletions src/PlanOnlyPlugin.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import static TerraformEnvironmentStage.APPLY
import static TerraformEnvironmentStage.CONFIRM

class PlanOnlyPlugin implements TerraformEnvironmentStagePlugin, TerraformPlanCommandPlugin {

public static void init() {
PlanOnlyPlugin plugin = new PlanOnlyPlugin()

Jenkinsfile.instance.addParam([
$class: 'hudson.model.BooleanParameterDefinition',
name: "FAIL_PLAN_ON_CHANGES",
defaultValue: false,
description: 'Plan run with -detailed-exitcode; ANY CHANGES will cause failure'
])

TerraformPlanCommand.addPlugin(plugin)
TerraformEnvironmentStage.addPlugin(plugin)
}

public Closure skipStage(String stageName) {
return { closure ->
echo "Skipping ${stageName} stage. PlanOnlyPlugin is enabled."
}
}

@Override
public void apply(TerraformEnvironmentStage stage) {
stage.decorateAround(CONFIRM, skipStage(CONFIRM))
stage.decorateAround(APPLY, skipStage(APPLY))
}

@Override
public void apply(TerraformPlanCommand command) {
if (Jenkinsfile.instance.getEnv().FAIL_PLAN_ON_CHANGES == 'true') {
// set -e: fail on error
// set -o pipefail: return non-zero exit code if any command fails.
// useful when commands are piped together (ie. `terraform plan | landscape`)
command.withPrefix('set -e; set -o pipefail;')
command.withArgument('-detailed-exitcode')
}
}
}
32 changes: 8 additions & 24 deletions src/TargetPlugin.groovy
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import static TerraformEnvironmentStage.ALL

class TargetPlugin implements TerraformPlanCommandPlugin, TerraformApplyCommandPlugin, TerraformEnvironmentStagePlugin {
class TargetPlugin implements TerraformPlanCommandPlugin, TerraformApplyCommandPlugin {
public static void init() {
TargetPlugin plugin = new TargetPlugin()

Jenkinsfile.instance.addParam([
$class: 'hudson.model.StringParameterDefinition',
name: "RESOURCE_TARGETS",
defaultValue: '',
description: 'comma-separated list of resource addresses to pass to plan and apply "-target=" parameters'
])

TerraformPlanCommand.addPlugin(plugin)
TerraformApplyCommand.addPlugin(plugin)
TerraformEnvironmentStage.addPlugin(plugin)
}

@Override
Expand All @@ -26,24 +30,4 @@ class TargetPlugin implements TerraformPlanCommandPlugin, TerraformApplyCommandP
.findAll { item -> item != '' }
.each { item -> command.withArgument("-target ${item}") }
}

@Override
public void apply(TerraformEnvironmentStage stage) {
stage.decorate(ALL, addBuildParams())
}

public static Closure addBuildParams() {
return { closure ->
def params = [
string(name: 'RESOURCE_TARGETS', defaultValue: '', description: 'comma-separated list of resource addresses to pass to plan and apply "-target=" parameters'),
]
def props = [
parameters(params)
]
properties(props)

closure()
}
}

}
104 changes: 104 additions & 0 deletions test/PlanOnlyPluginTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import static org.hamcrest.Matchers.containsString
import static org.hamcrest.Matchers.hasItem
import static org.hamcrest.Matchers.instanceOf
import static org.hamcrest.Matchers.not
import static org.junit.Assert.assertThat
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import org.junit.Test
import org.junit.Before
import org.junit.After
import org.junit.runner.RunWith
import de.bechte.junit.runners.context.HierarchicalContextRunner

@RunWith(HierarchicalContextRunner.class)
class PlanOnlyPluginTest {
@Before
void resetJenkinsEnv() {
Jenkinsfile.instance = mock(Jenkinsfile.class)
when(Jenkinsfile.instance.getEnv()).thenReturn([:])
}

private configureJenkins(Map config = [:]) {
Jenkinsfile.instance = mock(Jenkinsfile.class)
when(Jenkinsfile.instance.getEnv()).thenReturn(config.env ?: [:])
}

public class Init {
@After
void resetPlugins() {
TerraformPlanCommand.resetPlugins()
TerraformEnvironmentStage.reset()
}

@Test
void modifiesTerraformEnvironmentStageCommand() {
PlanOnlyPlugin.init()

Collection actualPlugins = TerraformEnvironmentStage.getPlugins()
assertThat(actualPlugins, hasItem(instanceOf(PlanOnlyPlugin.class)))
}

@Test
void addsParameter() {
PlanOnlyPlugin.init()

Collection actualParms = Jenkinsfile.instance.params
assertThat(actualParms, hasItem([
$class: 'hudson.model.BooleanParameterDefinition',
name: "FAIL_PLAN_ON_CHANGES",
defaultValue: false,
description: 'Plan run with -detailed-exitcode; ANY CHANGES will cause failure'
]))
}
}

public class Apply {

@Test
void decoratesTheTerraformEnvironmentStage() {
PlanOnlyPlugin plugin = new PlanOnlyPlugin()
def environment = spy(new TerraformEnvironmentStage())
plugin.apply(environment)

verify(environment, times(1)).decorateAround(eq(TerraformEnvironmentStage.CONFIRM), any(Closure.class))
verify(environment, times(1)).decorateAround(eq(TerraformEnvironmentStage.APPLY), any(Closure.class))
}

@Test
void addsArgumentToTerraformPlan() {
PlanOnlyPlugin plugin = new PlanOnlyPlugin()
TerraformPlanCommand command = new TerraformPlanCommand()
configureJenkins(env: [
'FAIL_PLAN_ON_CHANGES': 'true'
])

plugin.apply(command)

String result = command.toString()
assertThat(result, containsString("-detailed-exitcode"))
assertThat(result, containsString("set -e; set -o pipefail"))
}

@Test
void doesNotAddArgumentToTerraformPlan() {
PlanOnlyPlugin plugin = new PlanOnlyPlugin()
TerraformPlanCommand command = new TerraformPlanCommand()
configureJenkins(env: [
'FAIL_PLAN_ON_CHANGES': 'false'
])

plugin.apply(command)

String result = command.toString()
assertThat(result, not(containsString("-detailed-exitcode")))
assertThat(result, not(containsString("set -e; set -o pipefail")))
}
}

}
43 changes: 8 additions & 35 deletions test/TargetPluginTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import static org.hamcrest.Matchers.hasItem
import static org.hamcrest.Matchers.instanceOf
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static TerraformEnvironmentStage.ALL;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;

Expand Down Expand Up @@ -36,7 +30,6 @@ class TargetPluginTest {
void resetPlugins() {
TerraformPlanCommand.resetPlugins()
TerraformApplyCommand.resetPlugins()
TerraformEnvironmentStage.reset()
}

@Test
Expand All @@ -56,11 +49,16 @@ class TargetPluginTest {
}

@Test
void modifiesTerraformEnvironmentStageCommand() {
void addsParameter() {
TargetPlugin.init()

Collection actualPlugins = TerraformEnvironmentStage.getPlugins()
assertThat(actualPlugins, hasItem(instanceOf(TargetPlugin.class)))
Collection actualParms = Jenkinsfile.instance.params
assertThat(actualParms, hasItem([
$class: 'hudson.model.StringParameterDefinition',
name: "RESOURCE_TARGETS",
defaultValue: '',
description: 'comma-separated list of resource addresses to pass to plan and apply "-target=" parameters'
]))
}
}

Expand Down Expand Up @@ -121,31 +119,6 @@ class TargetPluginTest {
assertThat(result, not(containsString("-target")))
}

@Test
void decoratesTheTerraformEnvironmentStage() {
TargetPlugin plugin = new TargetPlugin()
def environment = spy(new TerraformEnvironmentStage())
configureJenkins(env: [
'RESOURCE_TARGETS': 'aws_dynamodb_table.test-table-2,aws_dynamodb_table.test-table-3'
])

plugin.apply(environment)

verify(environment, times(1)).decorate(eq(TerraformEnvironmentStage.ALL), any(Closure.class))
}
}

class AddBuildParams {
@Test
void runsInnerClosure() {
def addParamsClosure = TargetPlugin.addBuildParams()
def innerClosure = spy { -> }
def jenkinsfile = new DummyJenkinsfile()

addParamsClosure.delegate = jenkinsfile
addParamsClosure(innerClosure)

verify(innerClosure).call()
}
}
}

0 comments on commit eb814f9

Please sign in to comment.