Skip to content

Commit a7a02dd

Browse files
authored
Merge pull request #348 from duckpuppy/add_terraform_outputs
Issue 347: Add TerraformOutputOnlyPlugin
2 parents 198c029 + 8922c68 commit a7a02dd

9 files changed

+433
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
* [Issue #344](https://github.com/manheim/terraform-pipeline/issues/344) Add PLAN_ONLY parameter to PlanOnlyPlugin
44
* **BREAKING CHANGE** This change is a breaking change. Prior to this update, applying the PlanOnlyPlugin would restrict the pipeline to only running `terraform plan`. This update changes behavior to simply providing a `PLAN_ONLY` boolean parameter that can be set to restrict the build behavior. It defaults to `false`.
5+
* [Issue #347](https://github.com/manheim/terraform-pipeline/pull/348)
6+
Feature: TerraformOutputOnlyPlugin - can restrict a pipeline run to
7+
displaying the current state outputs only via new job parameters.
58

69
# v5.15
710

docs/TerraformOutputOnlyPlugin.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## [TerraformOutputOnlyPlugin](../src/TerraformOutputOnlyPlugin.groovy)
2+
3+
Enable this plugin to change pipeline functionality. This plugin will skip the plan and apply stages and add three new job parameters.
4+
5+
* `SHOW_OUTPUTS_ONLY`: This configures the job to skip execution of the plan and apply terraform commands. The job will perform the INIT stage and immediately perform `terraform output`. Unless this option is checked, the following options will have no effect.
6+
* `JSON_FORMAT_OUTPUTS`: This will instruct the plugin to display the output in JSON format.
7+
* `REDIRECT_OUTPUTS_TO_FILE`: Text entered into this option will be used to redirect the result of `terraform output` to a file in the current workspace. The filename should be relative to the workspace, and directories will NOT be created so they should exist beforehand.
8+
9+
```
10+
// Jenkinsfile
11+
@Library(['terraform-pipeline@v3.10']) _
12+
13+
Jenkinsfile.init(this, env)
14+
15+
// This enables the "output only" functionality
16+
TerraformOutputOnlyPlugin.init()
17+
18+
def validate = new TerraformValidateStage()
19+
20+
def destroyQa = new TerraformEnvironmentStage('qa')
21+
def destroyUat = new TerraformEnvironmentStage('uat')
22+
def destroyProd = new TerraformEnvironmentStage('prod')
23+
24+
validate.then(destroyQa)
25+
.then(destroyUat)
26+
.then(destroyProd)
27+
.build()
28+
```

src/PassPlanFilePlugin.groovy

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import static TerraformEnvironmentStage.PLAN
2-
import static TerraformEnvironmentStage.APPLY
1+
import static TerraformEnvironmentStage.PLAN_COMMAND
2+
import static TerraformEnvironmentStage.APPLY_COMMAND
33

44
class PassPlanFilePlugin implements TerraformPlanCommandPlugin, TerraformApplyCommandPlugin, TerraformEnvironmentStagePlugin {
55

@@ -13,8 +13,8 @@ class PassPlanFilePlugin implements TerraformPlanCommandPlugin, TerraformApplyCo
1313

1414
@Override
1515
public void apply(TerraformEnvironmentStage stage) {
16-
stage.decorate(PLAN, stashPlan(stage.getEnvironment()))
17-
stage.decorate(APPLY, unstashPlan(stage.getEnvironment()))
16+
stage.decorate(PLAN_COMMAND, stashPlan(stage.getEnvironment()))
17+
stage.decorate(APPLY_COMMAND, unstashPlan(stage.getEnvironment()))
1818
}
1919

2020
@Override

src/TerraformOutputCommand.groovy

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
class TerraformOutputCommand implements TerraformCommand, Resettable {
2+
private static final DEFAULT_PLUGINS = []
3+
private String command = "output"
4+
private boolean json = false
5+
private String redirectFile
6+
private String terraformBinary = "terraform"
7+
String environment
8+
private String stateFilePath
9+
private static plugins = DEFAULT_PLUGINS.clone()
10+
private appliedPlugins = []
11+
12+
public TerraformOutputCommand(String environment) {
13+
this.environment = environment
14+
}
15+
16+
public TerraformOutputCommand withJson(boolean json) {
17+
this.json = json
18+
return this
19+
}
20+
21+
public TerraformOutputCommand withRedirectFile(String redirectFile) {
22+
this.redirectFile = redirectFile
23+
return this
24+
}
25+
26+
public String toString() {
27+
applyPluginsOnce()
28+
29+
def pieces = []
30+
pieces << terraformBinary
31+
pieces << command
32+
33+
if (json) {
34+
pieces << "-json"
35+
}
36+
37+
if (redirectFile) {
38+
pieces << ">${redirectFile}"
39+
}
40+
41+
return pieces.join(' ')
42+
}
43+
44+
private applyPluginsOnce() {
45+
def remainingPlugins = plugins - appliedPlugins
46+
47+
for (TerraformOutputCommandPlugin plugin in remainingPlugins) {
48+
plugin.apply(this)
49+
appliedPlugins << plugin
50+
}
51+
}
52+
53+
public static addPlugin(TerraformOutputCommandPlugin plugin) {
54+
plugins << plugin
55+
}
56+
57+
public static TerraformOutputCommand instanceFor(String environment) {
58+
return new TerraformOutputCommand(environment).withJson(false)
59+
}
60+
61+
public static getPlugins() {
62+
return plugins
63+
}
64+
65+
public static reset() {
66+
this.plugins = DEFAULT_PLUGINS.clone()
67+
}
68+
69+
public String getEnvironment() {
70+
return environment
71+
}
72+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
interface TerraformOutputCommandPlugin {
2+
public void apply(TerraformOutputCommand command)
3+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import static TerraformEnvironmentStage.INIT_COMMAND
2+
import static TerraformEnvironmentStage.PLAN_COMMAND
3+
import static TerraformEnvironmentStage.APPLY
4+
import static TerraformEnvironmentStage.CONFIRM
5+
6+
class TerraformOutputOnlyPlugin implements TerraformEnvironmentStagePlugin, TerraformOutputCommandPlugin {
7+
8+
public static void init() {
9+
TerraformOutputOnlyPlugin plugin = new TerraformOutputOnlyPlugin()
10+
11+
BuildWithParametersPlugin.withBooleanParameter([
12+
name: "SHOW_OUTPUTS_ONLY",
13+
description: "Only run 'terraform output' to show outputs, skipping plan and apply."
14+
])
15+
BuildWithParametersPlugin.withBooleanParameter([
16+
name: "JSON_FORMAT_OUTPUTS",
17+
description: "Render 'terraform output' results as JSON. Only applies if SHOW_OUTPUTS_ONLY is selected."
18+
])
19+
BuildWithParametersPlugin.withStringParameter([
20+
name: "REDIRECT_OUTPUTS_TO_FILE",
21+
description: "Filename relative to the current workspace to redirect output to. Only applies if 'SHOW_OUTPUTS_ONLY' is selected."
22+
])
23+
24+
TerraformEnvironmentStage.addPlugin(plugin)
25+
TerraformOutputCommand.addPlugin(plugin)
26+
}
27+
28+
public Closure skipStage(String stageName) {
29+
return { closure ->
30+
echo "Skipping ${stageName} stage. TerraformOutputOnlyPlugin is enabled."
31+
}
32+
}
33+
34+
public Closure runTerraformOutputCommand(String environment) {
35+
def outputCommand = TerraformOutputCommand.instanceFor(environment)
36+
return { closure ->
37+
closure()
38+
echo "Running 'terraform output'. TerraformOutputOnlyPlugin is enabled."
39+
sh outputCommand.toString()
40+
}
41+
}
42+
43+
@Override
44+
public void apply(TerraformEnvironmentStage stage) {
45+
if (Jenkinsfile.instance.getEnv().SHOW_OUTPUTS_ONLY == 'true') {
46+
stage.decorate(INIT_COMMAND, runTerraformOutputCommand(stage.getEnvironment()))
47+
stage.decorate(PLAN_COMMAND, skipStage(PLAN_COMMAND))
48+
stage.decorateAround(CONFIRM, skipStage(CONFIRM))
49+
stage.decorateAround(APPLY, skipStage(APPLY))
50+
}
51+
}
52+
53+
@Override
54+
public void apply(TerraformOutputCommand command) {
55+
if (Jenkinsfile.instance.getEnv().JSON_FORMAT_OUTPUTS == 'true') {
56+
command.withJson(true)
57+
}
58+
if (Jenkinsfile.instance.getEnv().REDIRECT_OUTPUTS_TO_FILE) {
59+
command.withRedirectFile(Jenkinsfile.instance.getEnv().REDIRECT_OUTPUTS_TO_FILE)
60+
}
61+
}
62+
}

test/PassPlanFilePluginTest.groovy

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ class PassPlanFilePluginTest {
5252
def environment = spy(new TerraformEnvironmentStage())
5353
plugin.apply(environment)
5454

55-
verify(environment, times(1)).decorate(eq(TerraformEnvironmentStage.PLAN), any(Closure.class))
56-
verify(environment, times(1)).decorate(eq(TerraformEnvironmentStage.APPLY), any(Closure.class))
55+
verify(environment, times(1)).decorate(eq(TerraformEnvironmentStage.PLAN_COMMAND), any(Closure.class))
56+
verify(environment, times(1)).decorate(eq(TerraformEnvironmentStage.APPLY_COMMAND), any(Closure.class))
5757
}
5858

5959
@Test
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import static org.hamcrest.Matchers.containsString
2+
import static org.hamcrest.Matchers.not
3+
import static org.hamcrest.MatcherAssert.assertThat
4+
import static org.mockito.Mockito.mock
5+
import static org.mockito.Mockito.times
6+
import static org.mockito.Mockito.verify
7+
8+
import org.junit.jupiter.api.Nested
9+
import org.junit.jupiter.api.Test
10+
import org.junit.jupiter.api.extension.ExtendWith
11+
12+
@ExtendWith(ResetStaticStateExtension.class)
13+
class TerraformOutputCommandTest {
14+
@Nested
15+
public class WithJson {
16+
@Test
17+
void defaultsToFalse() {
18+
def command = new TerraformOutputCommand()
19+
20+
def actualCommand = command.toString()
21+
assertThat(actualCommand, not(containsString("-json")))
22+
}
23+
24+
@Test
25+
void skipsJsonFlagWhenFalse() {
26+
def command = new TerraformOutputCommand().withJson(false)
27+
28+
def actualCommand = command.toString()
29+
assertThat(actualCommand, not(containsString(" -json")))
30+
}
31+
32+
@Test
33+
void addsJsonFlagWhenTrue() {
34+
def command = new TerraformOutputCommand().withJson(true)
35+
36+
def actualCommand = command.toString()
37+
assertThat(actualCommand, containsString(" -json"))
38+
}
39+
}
40+
41+
@Nested
42+
public class WithRedirectFile {
43+
@Test
44+
void defaultsToEmpty() {
45+
def command = new TerraformOutputCommand()
46+
47+
def actualCommand = command.toString()
48+
assertThat(actualCommand, not(containsString(">")))
49+
}
50+
51+
@Test
52+
void addsRedirectWhenSet() {
53+
def command = new TerraformOutputCommand().withRedirectFile("foo")
54+
55+
def actualCommand = command.toString()
56+
assertThat(actualCommand, containsString(">foo"))
57+
}
58+
}
59+
60+
@Nested
61+
public class Plugins {
62+
@Test
63+
void areAppliedToTheCommand() {
64+
TerraformOutputCommandPlugin plugin = mock(TerraformOutputCommandPlugin.class)
65+
TerraformOutputCommand.addPlugin(plugin)
66+
67+
TerraformOutputCommand command = TerraformOutputCommand.instanceFor("env")
68+
command.toString()
69+
70+
verify(plugin).apply(command)
71+
}
72+
73+
@Test
74+
void areAppliedExactlyOnce() {
75+
TerraformOutputCommandPlugin plugin = mock(TerraformOutputCommandPlugin.class)
76+
TerraformOutputCommand.addPlugin(plugin)
77+
78+
TerraformOutputCommand command = TerraformOutputCommand.instanceFor("env")
79+
80+
String firstCommand = command.toString()
81+
String secondCommand = command.toString()
82+
83+
verify(plugin, times(1)).apply(command)
84+
}
85+
86+
@Test
87+
void areAppliedEvenAfterCommandAlreadyInstantiated() {
88+
TerraformOutputCommandPlugin firstPlugin = mock(TerraformOutputCommandPlugin.class)
89+
TerraformOutputCommandPlugin secondPlugin = mock(TerraformOutputCommandPlugin.class)
90+
91+
TerraformOutputCommand.addPlugin(firstPlugin)
92+
TerraformOutputCommand command = TerraformOutputCommand.instanceFor("env")
93+
94+
TerraformOutputCommand.addPlugin(secondPlugin)
95+
96+
command.toString()
97+
98+
verify(secondPlugin, times(1)).apply(command)
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)