Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
622724c
initial commit
jleopold28 Aug 31, 2020
c9c9112
change branch for apply
jleopold28 Aug 31, 2020
389e975
testing workspace
jleopold28 Aug 31, 2020
60b50bd
test pwd
jleopold28 Aug 31, 2020
368bb09
testing echo
jleopold28 Aug 31, 2020
a657e42
Testing pwd with appending file name
jleopold28 Aug 31, 2020
460e8a8
fix syntax
jleopold28 Aug 31, 2020
41a5966
adding envi=ronment name
jleopold28 Aug 31, 2020
edfba49
testing archive artifacts
jleopold28 Sep 1, 2020
2d58569
Testing archive
jleopold28 Sep 1, 2020
3f326cc
testing download archive
jleopold28 Sep 1, 2020
088f269
fix import
jleopold28 Sep 1, 2020
f17714b
testing with jobName
jleopold28 Sep 1, 2020
bae17af
eccho vars
jleopold28 Sep 1, 2020
203bdc4
testing splitting job name
jleopold28 Sep 1, 2020
aa84e0e
testing new split method
jleopold28 Sep 1, 2020
4c78ff1
fix loop typo
jleopold28 Sep 1, 2020
72581b3
adding new getArtifactUrl method
jleopold28 Sep 1, 2020
bf31317
cleanup
jleopold28 Sep 1, 2020
bc95cc7
adding tests
jleopold28 Sep 1, 2020
8e44fce
fix style
jleopold28 Sep 1, 2020
6b6a3b8
remove plan and apply plugin additions
jleopold28 Sep 1, 2020
0b1bf1b
update tests
jleopold28 Sep 1, 2020
6999cf9
Adding tests for url
jleopold28 Sep 1, 2020
0de7b3a
specify directoy instead of argument
jleopold28 Sep 1, 2020
c5e91f9
revert changes to conditional apply
jleopold28 Sep 1, 2020
afdc7a0
Test PR 7
jleopold28 Sep 1, 2020
b3faf85
switch branch to master
jleopold28 Sep 1, 2020
064b63f
Testing stash
jleopold28 Sep 1, 2020
4bb35c2
simplify
jleopold28 Sep 1, 2020
ae9dc44
stash based on filename
jleopold28 Sep 1, 2020
07ec943
remove tests for url generation
jleopold28 Sep 1, 2020
4b3575d
update tests
jleopold28 Sep 2, 2020
4ad3227
fix merge conflicts
jleopold28 Sep 2, 2020
f32e800
update docs
jleopold28 Sep 2, 2020
06501bf
update tests to verify closure
jleopold28 Sep 2, 2020
4e80af6
fix naming
jleopold28 Sep 2, 2020
712ab07
adding delegate
jleopold28 Sep 2, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Unreleased

* [Issue #293](https://github.com/manheim/terraform-pipeline/issues/293) withEnv & withGlobalEnv docs
* [Issue #175](https://github.com/manheim/terraform-pipeline/issues/175) Pass terraform plan output to apply

# v5.10

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ The example above gives you a bare-bones pipeline, and there may be Jenkinsfile
* [CrqPlugin](./docs/CrqPlugin.md): Use the manheim_remedier gem to open automated Change Requests.
* [DestroyPlugin](./docs/DestroyPlugin.md): Use this to change the pipeline functionality to `terraform destroy`. (Requires manual confirmation)
* [GithubPRPlanPlugin](./docs/GithubPRPlanPlugin.md): Use this to post Terraform plan results in the comments of a Github PullRequest.
* [PassPlanFilePlugin](./docs/PassPlanFilePlugin.md): Pass the plan file into apply stage
* [PlanOnlyPlugin](./docs/PlanOnlyPlugin.md): Use this to change the pipeline functionality to `terraform plan` only.
* [TargetPlugin](./docs/TargetPlugin.md): set `-target` parameter for terraform plan and apply.
* [TerraformDirectoryPlugin](./docs/TerraformDirectoryPlugin.md): Change the default directory containing your terraform code.
Expand Down
28 changes: 28 additions & 0 deletions docs/PassPlanFilePlugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## [PassPlanFilePlugin](../src/PassPlanFilePlugin.groovy)

Enable this plugin to pass the plan file output to `terraform apply`.

This plugin stashes the plan file during the `plan` step.
When `apply` is called, the plan file is unstashed and passed as an argument.


```
// Jenkinsfile
@Library(['terraform-pipeline@v3.10']) _

Jenkinsfile.init(this, env)

// Pass the plan file to 'terraform apply'
PassPlanFilePlugin.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()
```
50 changes: 50 additions & 0 deletions src/PassPlanFilePlugin.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import static TerraformEnvironmentStage.PLAN
import static TerraformEnvironmentStage.APPLY

class PassPlanFilePlugin implements TerraformPlanCommandPlugin, TerraformApplyCommandPlugin, TerraformEnvironmentStagePlugin {

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

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

@Override
public void apply(TerraformEnvironmentStage stage) {
stage.decorate(PLAN, stashPlan(stage.getEnvironment()))
stage.decorate(APPLY, unstashPlan(stage.getEnvironment()))
}

@Override
public void apply(TerraformPlanCommand command) {
String env = command.getEnvironment()
command.withArgument("-out=tfplan-" + env)
}

@Override
public void apply(TerraformApplyCommand command) {
String env = command.getEnvironment()
command.withDirectory("tfplan-" + env)
}

public Closure stashPlan(String env) {
return { closure ->
closure()
String planFile = "tfplan-" + env
echo "Stashing ${planFile} file"
stash name: planFile, includes: planFile
}
}

public Closure unstashPlan(String env) {
return { closure ->
String planFile = "tfplan-" + env
echo "Unstashing ${planFile} file"
unstash planFile
closure()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. The change to use stash/unstash looks great =).

}

}
125 changes: 125 additions & 0 deletions test/PassPlanFilePluginTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import static org.hamcrest.Matchers.containsString
import static org.hamcrest.Matchers.hasItem
import static org.hamcrest.Matchers.instanceOf
import static org.junit.Assert.assertThat
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyString;
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 PassPlanFilePluginTest {
@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()
TerraformApplyCommand.resetPlugins()
TerraformEnvironmentStage.reset()
}

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

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

@Test
void modifiesTerraformPlanCommand() {
PassPlanFilePlugin.init()

Collection actualPlugins = TerraformPlanCommand.getPlugins()
assertThat(actualPlugins, hasItem(instanceOf(PassPlanFilePlugin.class)))
}

@Test
void modifiesTerraformApplyCommand() {
PassPlanFilePlugin.init()

Collection actualPlugins = TerraformApplyCommand.getPlugins()
assertThat(actualPlugins, hasItem(instanceOf(PassPlanFilePlugin.class)))
}

}

public class Apply {

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

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

@Test
void runsStashPlan() {
def expectedClosure = { -> }
def plugin = spy(new PassPlanFilePlugin())
doReturn(expectedClosure).when(plugin).stashPlan()
def stage = mock(TerraformEnvironmentStage.class)

plugin.apply(stage)

verify(stage).decorate(anyString(), eq(expectedClosure))
}

@Test
void runsUnstashPlan() {
def expectedClosure = { -> }
def plugin = spy(new PassPlanFilePlugin())
doReturn(expectedClosure).when(plugin).unstashPlan()
def stage = mock(TerraformEnvironmentStage.class)

plugin.apply(stage)

verify(stage).decorate(anyString(), eq(expectedClosure))
}

Copy link
Collaborator

@kmanning kmanning Sep 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the new runsStashPlan runsUnstashPlan tests - thanks for adding that!

I'm looking at the code coverage however, and it doesn't seem to have changed. Digging into the details of the test, it looks like you're mocking the closures, and the closures are the lines of code missing coverage.

The closure is the thing that I was interested in getting coverage on. If there were, say, a syntactic error, simply exercising the closure would catch that. Instead of exercising stashPlan and unstashPlan through apply, could we break it down to a more fine-grained unit test, exercise those methods directly?

Something along the lines of:

class UnstashPlan {
        @Test
        void justExercisingNotValidatingBehavior() {
            def plugin = new PassPlanFilePlugin()

            def unstashClosure = plugin.unstashPlan('myenv')
            unstashClosure.call { -> }
        }
}

^-- if you did the above, we should see code coverage go up, because we're actually exercising the closure.

If we wanted to take this further, we could even validate some amount of behavior. It's really important that the inner closure is executed - if calling the inner closure were mistakenly left off, it would have really detrimental impact on the behavior of the pipeline. So we could build more safety into our unit tests with something like:

class UnstashPlan {
        @Test
        void executesPassedClosure() {
            def wasCalled = false
            def passedClosure = { -> wasCalled = true }
            def plugin = new PassPlanFilePlugin()

            def unstashClosure = plugin.unstashPlan('myenv')
            unstashClosure.call(passedClosure)

            assertTrue(wasCalled)
        }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kmanning I tried implementing that second block but I'm getting some errors.

Any ideas what to do about groovy.lang.MissingMethodException: No signature of method: PassPlanFilePlugin.echo() is applicable for argument types: (org.codehaus.groovy.runtime.GStringImpl) values: [Stashing tfplan-dev file]

Copy link
Collaborator

@kmanning kmanning Sep 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Your closure is running Jenkinsfile DSL methods, like echo.

    public Closure unstashPlan(String env) {
        return { closure ->
            String planFile = "tfplan-" + env
            echo "Unstashing ${planFile} file"
            unstash planFile
            closure()
        }

echo isn't defined in the plugin, and it's not defined in your test. When executing a closure, when the VM comes across a method, it does a search. See here:

  1. this
  2. owner
  3. delegate

If the method can't be found after traversing those objects, it throws the MissingMethodException you're seeing.

In a "normal" terraform-pipeline, the owner or delegate would be the actual Jenkinsfile executing on Jenkins. But you're just in a unit test - you don't have access to the normal Jenkinsfile DSL methods. So what you can do is explicitly set a delegate, and use the DummyJenkinsfile to stub in for what would normally happen in Jenkins.

As an example, you can check out this test:

@Test
void doesNotBlowUpWhenRunningClosure() {
Jenkinsfile.instance = spy(new Jenkinsfile())
doReturn([:]).when(Jenkinsfile.instance).getEnv()
Jenkinsfile.defaultNodeName = 'foo'
def stage = new TerraformEnvironmentStage('foo')
def closure = stage.pipelineConfiguration()
closure.delegate = new DummyJenkinsfile()
closure()
}

@Test
void addsArgumentToTerraformPlan() {
PassPlanFilePlugin plugin = new PassPlanFilePlugin()
TerraformPlanCommand command = new TerraformPlanCommand("dev")
plugin.apply(command)

String result = command.toString()
assertThat(result, containsString("-out=tfplan-dev"))
}

@Test
void addsArgumentToTerraformApply() {
PassPlanFilePlugin plugin = new PassPlanFilePlugin()
TerraformApplyCommand command = new TerraformApplyCommand("dev")
plugin.apply(command)

String result = command.toString()
assertThat(result, containsString("tfplan-dev"))
}

}

}