From 4f8f2fe9c24a8ac8a88f31c0583187812f03baee Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Thu, 4 Feb 2021 14:55:18 -0500 Subject: [PATCH 01/13] Add initial taint command --- src/TerraformTaintCommand.groovy | 41 ++++++++++++++++++++++++++ src/TerraformTaintCommandPlugin.groovy | 0 2 files changed, 41 insertions(+) create mode 100644 src/TerraformTaintCommand.groovy create mode 100644 src/TerraformTaintCommandPlugin.groovy diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy new file mode 100644 index 000000000..447ca7075 --- /dev/null +++ b/src/TerraformTaintCommand.groovy @@ -0,0 +1,41 @@ +class TerraformTaintCommand implements TerraformCommand { + private static DEFAULT_BRANCHES = ['master'] + private static branches = DEFAULT_BRANCHES + + String environment + private String originRepoSlug = "" + private String terraformBinary = "terraform" + private String command = "taint" + private String args = [] + private static plugins = [] + private appliedPlugins = [] + + public TerraformTaintCommand(String environment) { + this.environment = environment + this.plugins = BuildWithParametersPlugin + } + + public TerraformTaintCommand withOriginRepo(String originRepoSlug) { + this.originRepoSlug = originRepoSlug + return this + } + + public TerraformTaintCommand onMasterOnly() { + this.branches = ['master'] + return this + } + + public TerraformTaintCommand onAnyBranch() { + this.branches = ['any'] + return this + } + + public TerraformTaintCommand onBranch(String branchName) { + this.branches << branchName + return this + } + + public String getEnvironment() { + return this.environment + } +} \ No newline at end of file diff --git a/src/TerraformTaintCommandPlugin.groovy b/src/TerraformTaintCommandPlugin.groovy new file mode 100644 index 000000000..e69de29bb From 4c2a0cc0ae2956d4d37cf1bb3408f388f0984cd6 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Tue, 23 Feb 2021 08:36:53 -0500 Subject: [PATCH 02/13] WIP: Taint/Untaint code --- src/TerraformTaintCommand.groovy | 28 +++++++++++++++++++++++++- src/TerraformTaintCommandPlugin.groovy | 26 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 447ca7075..4209fde1d 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -38,4 +38,30 @@ class TerraformTaintCommand implements TerraformCommand { public String getEnvironment() { return this.environment } -} \ No newline at end of file + + public String toString() { + applyPluginsOnce() + + def pattern + def parts = [] + parts << terraformBinary + parts << command + + parts.removeAll { it == null } + return parts.join(' ') + } + + public static addPlugin(TerraformFormatCommandPlugin plugin) { + this.globalPlugins << plugin + } + + private applyPluginsOnce() { + def remainingPlugins = globalPlugins - appliedPlugins + + for (TerraformFormatCommandPlugin plugin in remainingPlugins) { + plugin.apply(this) + appliedPlugins << plugin + } + } +} + diff --git a/src/TerraformTaintCommandPlugin.groovy b/src/TerraformTaintCommandPlugin.groovy index e69de29bb..f617413e0 100644 --- a/src/TerraformTaintCommandPlugin.groovy +++ b/src/TerraformTaintCommandPlugin.groovy @@ -0,0 +1,26 @@ +interface TerraformTaintCommandPlugin { + public void apply(TerraformTaintCommand command) +} + +public class TerraformTaintCommandPluginImpl implements TerraformEnvironmentStagePlugin, TerraformTaintCommandPlugin { + private String[] resources + + public static void init() { + TerraformTaintCommandPlugin plugin = new TerraformTaintCommandPluginImpl(); + + BuildWithParametersPlugin.withStringParameter([ + name: "TAINT_RESOURCES", + description: 'Run \`terraform taint\` on the resources specified prior to planning and applying.' + ]) + + BuildWithParametersPlugin.withStringParameter([ + name: "UNTAINT_RESOURCES", + description: 'Run \`terraform untaint\` on the resources specified prior to planning and applying.' + ]) + + TerraformEnvironmentStage.addPlugin(plugin) + } + + public Closure onlyOnExpectedBranch() {} + public boolean shouldApply() {} +} \ No newline at end of file From 83ac06f96ce8a2631364051219185a88dd9d492f Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Mon, 1 Mar 2021 10:49:47 -0500 Subject: [PATCH 03/13] Fix resource parameter handling --- src/TerraformTaintCommand.groovy | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 4209fde1d..acdb9292b 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -6,7 +6,7 @@ class TerraformTaintCommand implements TerraformCommand { private String originRepoSlug = "" private String terraformBinary = "terraform" private String command = "taint" - private String args = [] + private String resource private static plugins = [] private appliedPlugins = [] @@ -35,6 +35,10 @@ class TerraformTaintCommand implements TerraformCommand { return this } + public TerraformTaintCommand withResource(String resource) { + this.resource = resource + } + public String getEnvironment() { return this.environment } @@ -42,10 +46,10 @@ class TerraformTaintCommand implements TerraformCommand { public String toString() { applyPluginsOnce() - def pattern def parts = [] parts << terraformBinary parts << command + parts << resource parts.removeAll { it == null } return parts.join(' ') From 39bfbb6563b2760a57ddfca1df93835dbec3ec25 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Thu, 4 Mar 2021 08:30:28 -0500 Subject: [PATCH 04/13] Use new Pluggable trait to support plugins --- src/TerraformTaintCommand.groovy | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index acdb9292b..2d8683038 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -1,14 +1,10 @@ -class TerraformTaintCommand implements TerraformCommand { +class TerraformTaintCommand implements TerraformCommand, Pluggable { private static DEFAULT_BRANCHES = ['master'] private static branches = DEFAULT_BRANCHES - String environment private String originRepoSlug = "" - private String terraformBinary = "terraform" private String command = "taint" private String resource - private static plugins = [] - private appliedPlugins = [] public TerraformTaintCommand(String environment) { this.environment = environment @@ -43,9 +39,7 @@ class TerraformTaintCommand implements TerraformCommand { return this.environment } - public String toString() { - applyPluginsOnce() - + public String assembleCommandString() { def parts = [] parts << terraformBinary parts << command @@ -54,18 +48,5 @@ class TerraformTaintCommand implements TerraformCommand { parts.removeAll { it == null } return parts.join(' ') } - - public static addPlugin(TerraformFormatCommandPlugin plugin) { - this.globalPlugins << plugin - } - - private applyPluginsOnce() { - def remainingPlugins = globalPlugins - appliedPlugins - - for (TerraformFormatCommandPlugin plugin in remainingPlugins) { - plugin.apply(this) - appliedPlugins << plugin - } - } } From 692f58b48819caf7d9f96794d7b9a7dd4c45be20 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Thu, 4 Mar 2021 13:14:16 -0500 Subject: [PATCH 05/13] Fix taint command, add tests --- src/TerraformTaintCommand.groovy | 50 ++++++---------- src/TerraformTaintCommandPlugin.groovy | 23 -------- test/TerraformTaintCommandTest.groovy | 79 ++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 56 deletions(-) create mode 100644 test/TerraformTaintCommandTest.groovy diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 2d8683038..99b6516c0 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -1,52 +1,36 @@ -class TerraformTaintCommand implements TerraformCommand, Pluggable { - private static DEFAULT_BRANCHES = ['master'] - private static branches = DEFAULT_BRANCHES - - private String originRepoSlug = "" +class TerraformTaintCommand implements TerraformCommand, Pluggable, Resettable { private String command = "taint" private String resource public TerraformTaintCommand(String environment) { this.environment = environment - this.plugins = BuildWithParametersPlugin } - public TerraformTaintCommand withOriginRepo(String originRepoSlug) { - this.originRepoSlug = originRepoSlug - return this - } + public TerraformTaintCommand withResource(String resource) { + this.resource = resource - public TerraformTaintCommand onMasterOnly() { - this.branches = ['master'] return this } - public TerraformTaintCommand onAnyBranch() { - this.branches = ['any'] - return this - } + public String assembleCommandString() { + if (resource) { + def parts = [] + parts << terraformBinary + parts << command + parts << resource - public TerraformTaintCommand onBranch(String branchName) { - this.branches << branchName - return this - } + parts.removeAll { it == null } + return parts.join(' ') + } - public TerraformTaintCommand withResource(String resource) { - this.resource = resource + return "echo \"No resource set, skipping 'terraform taint'." } - public String getEnvironment() { - return this.environment + public static reset() { + this.plugins = [] } - public String assembleCommandString() { - def parts = [] - parts << terraformBinary - parts << command - parts << resource - - parts.removeAll { it == null } - return parts.join(' ') + public String getResource() { + return this.resource } } - diff --git a/src/TerraformTaintCommandPlugin.groovy b/src/TerraformTaintCommandPlugin.groovy index f617413e0..247555897 100644 --- a/src/TerraformTaintCommandPlugin.groovy +++ b/src/TerraformTaintCommandPlugin.groovy @@ -1,26 +1,3 @@ interface TerraformTaintCommandPlugin { public void apply(TerraformTaintCommand command) } - -public class TerraformTaintCommandPluginImpl implements TerraformEnvironmentStagePlugin, TerraformTaintCommandPlugin { - private String[] resources - - public static void init() { - TerraformTaintCommandPlugin plugin = new TerraformTaintCommandPluginImpl(); - - BuildWithParametersPlugin.withStringParameter([ - name: "TAINT_RESOURCES", - description: 'Run \`terraform taint\` on the resources specified prior to planning and applying.' - ]) - - BuildWithParametersPlugin.withStringParameter([ - name: "UNTAINT_RESOURCES", - description: 'Run \`terraform untaint\` on the resources specified prior to planning and applying.' - ]) - - TerraformEnvironmentStage.addPlugin(plugin) - } - - public Closure onlyOnExpectedBranch() {} - public boolean shouldApply() {} -} \ No newline at end of file diff --git a/test/TerraformTaintCommandTest.groovy b/test/TerraformTaintCommandTest.groovy new file mode 100644 index 000000000..3c46f446d --- /dev/null +++ b/test/TerraformTaintCommandTest.groovy @@ -0,0 +1,79 @@ +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.not +import static org.hamcrest.Matchers.equalTo +import static org.hamcrest.MatcherAssert.assertThat +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.times +import static org.mockito.Mockito.verify + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ResetStaticStateExtension.class) +class TerraformTaintCommandTest { + @Nested + public class WithResource { + @Test + void defaultsToEmpty() { + def command = new TerraformTaintCommand() + + def actualCommand = command.toString() + assertThat(actualCommand, equalTo("echo \"No resource set, skipping 'terraform taint'.")) + } + + @Test + void addsResourceWhenSet() { + def command = new TerraformTaintCommand().withResource("foo") + + def actualCommand = command.toString() + assertThat(actualCommand, equalTo("terraform taint foo")) + } + } + + @Nested + public class Plugins { + @Test + void areAppliedToTheCommand() { + TerraformTaintCommandPlugin plugin = mock(TerraformTaintCommandPlugin.class) + TerraformTaintCommand.addPlugin(plugin) + + TerraformTaintCommand command = new TerraformTaintCommand() + command.environment = "env" + command.toString() + + verify(plugin).apply(command) + } + + @Test + void areAppliedExactlyOnce() { + TerraformTaintCommandPlugin plugin = mock(TerraformTaintCommandPlugin.class) + TerraformTaintCommand.addPlugin(plugin) + + TerraformTaintCommand command = new TerraformTaintCommand() + command.environment = "env" + + String firstCommand = command.toString() + String secondCommand = command.toString() + + verify(plugin, times(1)).apply(command) + } + + @Test + void areAppliedEvenAfterCommandAlreadyInstantiated() { + TerraformTaintCommandPlugin firstPlugin = mock(TerraformTaintCommandPlugin.class) + TerraformTaintCommandPlugin secondPlugin = mock(TerraformTaintCommandPlugin.class) + + TerraformTaintCommand.addPlugin(firstPlugin) + TerraformTaintCommand command = new TerraformTaintCommand() + command.environment = "env" + + TerraformTaintCommand.addPlugin(secondPlugin) + + command.toString() + + verify(firstPlugin, times(1)).apply(command) + verify(secondPlugin, times(1)).apply(command) + } + } +} From 79c5dfcc463bfd916e1824c3fc850de3e34576f2 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Thu, 4 Mar 2021 13:15:35 -0500 Subject: [PATCH 06/13] Add untaint command, add tests --- src/TerraformUntaintCommand.groovy | 37 +++++++++++ src/TerraformUntaintCommandPlugin.groovy | 3 + test/TerraformUntaintCommandTest.groovy | 79 ++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/TerraformUntaintCommand.groovy create mode 100644 src/TerraformUntaintCommandPlugin.groovy create mode 100644 test/TerraformUntaintCommandTest.groovy diff --git a/src/TerraformUntaintCommand.groovy b/src/TerraformUntaintCommand.groovy new file mode 100644 index 000000000..fb2ce95f1 --- /dev/null +++ b/src/TerraformUntaintCommand.groovy @@ -0,0 +1,37 @@ +class TerraformUntaintCommand implements TerraformCommand, Pluggable, Resettable { + private String command = "untaint" + private String resource + + public TerraformUntaintCommand(String environment) { + this.environment = environment + } + + public TerraformUntaintCommand withResource(String resource) { + this.resource = resource + + return this + } + + public String assembleCommandString() { + if (resource) { + def parts = [] + parts << terraformBinary + parts << command + parts << resource + + parts.removeAll { it == null } + return parts.join(' ') + } + + return "echo \"No resource set, skipping 'terraform untaint'." + } + + public static reset() { + this.plugins = [] + } + + public String getResource() { + return this.resource + } +} + diff --git a/src/TerraformUntaintCommandPlugin.groovy b/src/TerraformUntaintCommandPlugin.groovy new file mode 100644 index 000000000..785c694f2 --- /dev/null +++ b/src/TerraformUntaintCommandPlugin.groovy @@ -0,0 +1,3 @@ +interface TerraformUntaintCommandPlugin { + public void apply(TerraformUntaintCommand command) +} diff --git a/test/TerraformUntaintCommandTest.groovy b/test/TerraformUntaintCommandTest.groovy new file mode 100644 index 000000000..e4ef9f501 --- /dev/null +++ b/test/TerraformUntaintCommandTest.groovy @@ -0,0 +1,79 @@ +import static org.hamcrest.Matchers.containsString +import static org.hamcrest.Matchers.not +import static org.hamcrest.Matchers.equalTo +import static org.hamcrest.MatcherAssert.assertThat +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.times +import static org.mockito.Mockito.verify + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ResetStaticStateExtension.class) +class TerraformUntaintCommandTest { + @Nested + public class WithResource { + @Test + void defaultsToEmpty() { + def command = new TerraformUntaintCommand() + + def actualCommand = command.toString() + assertThat(actualCommand, equalTo("echo \"No resource set, skipping 'terraform untaint'.")) + } + + @Test + void addsResourceWhenSet() { + def command = new TerraformUntaintCommand().withResource("foo") + + def actualCommand = command.toString() + assertThat(actualCommand, equalTo("terraform untaint foo")) + } + } + + @Nested + public class Plugins { + @Test + void areAppliedToTheCommand() { + TerraformUntaintCommandPlugin plugin = mock(TerraformUntaintCommandPlugin.class) + TerraformUntaintCommand.addPlugin(plugin) + + TerraformUntaintCommand command = new TerraformUntaintCommand() + command.environment = "env" + command.toString() + + verify(plugin).apply(command) + } + + @Test + void areAppliedExactlyOnce() { + TerraformUntaintCommandPlugin plugin = mock(TerraformUntaintCommandPlugin.class) + TerraformUntaintCommand.addPlugin(plugin) + + TerraformUntaintCommand command = new TerraformUntaintCommand() + command.environment = "env" + + String firstCommand = command.toString() + String secondCommand = command.toString() + + verify(plugin, times(1)).apply(command) + } + + @Test + void areAppliedEvenAfterCommandAlreadyInstantiated() { + TerraformUntaintCommandPlugin firstPlugin = mock(TerraformUntaintCommandPlugin.class) + TerraformUntaintCommandPlugin secondPlugin = mock(TerraformUntaintCommandPlugin.class) + + TerraformUntaintCommand.addPlugin(firstPlugin) + TerraformUntaintCommand command = new TerraformUntaintCommand() + command.environment = "env" + + TerraformUntaintCommand.addPlugin(secondPlugin) + + command.toString() + + verify(firstPlugin, times(1)).apply(command) + verify(secondPlugin, times(1)).apply(command) + } + } +} From 32164765bc315876cbced32fa046232c55f1212f Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Thu, 4 Mar 2021 14:28:12 -0500 Subject: [PATCH 07/13] Add TerraformTaintPlugin and fix up all tests --- src/TerraformTaintCommand.groovy | 54 +++---- src/TerraformTaintPlugin.groovy | 121 +++++++++++++++ src/TerraformUntaintCommand.groovy | 54 +++---- test/TerraformTaintCommandTest.groovy | 2 - test/TerraformTaintPluginTest.groovy | 187 ++++++++++++++++++++++++ test/TerraformUntaintCommandTest.groovy | 2 - 6 files changed, 366 insertions(+), 54 deletions(-) create mode 100644 src/TerraformTaintPlugin.groovy create mode 100644 test/TerraformTaintPluginTest.groovy diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 99b6516c0..2df6bd86e 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -1,36 +1,40 @@ class TerraformTaintCommand implements TerraformCommand, Pluggable, Resettable { - private String command = "taint" - private String resource + private String command = "taint" + private String resource - public TerraformTaintCommand(String environment) { - this.environment = environment - } + public TerraformTaintCommand(String environment) { + this.environment = environment + } + + public TerraformTaintCommand withResource(String resource) { + this.resource = resource - public TerraformTaintCommand withResource(String resource) { - this.resource = resource + return this + } - return this - } + public String assembleCommandString() { + if (resource) { + def parts = [] + parts << terraformBinary + parts << command + parts << resource - public String assembleCommandString() { - if (resource) { - def parts = [] - parts << terraformBinary - parts << command - parts << resource + parts.removeAll { it == null } + return parts.join(' ') + } - parts.removeAll { it == null } - return parts.join(' ') + return "echo \"No resource set, skipping 'terraform taint'." } - return "echo \"No resource set, skipping 'terraform taint'." - } + public static reset() { + this.plugins = [] + } - public static reset() { - this.plugins = [] - } + public String getResource() { + return this.resource + } - public String getResource() { - return this.resource - } + public static TerraformTaintCommand instanceFor(String environment) { + return new TerraformTaintCommand(environment) + } } diff --git a/src/TerraformTaintPlugin.groovy b/src/TerraformTaintPlugin.groovy new file mode 100644 index 000000000..bc320c901 --- /dev/null +++ b/src/TerraformTaintPlugin.groovy @@ -0,0 +1,121 @@ +import static TerraformEnvironmentStage.PLAN_COMMAND + +class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, TerraformTaintCommandPlugin, TerraformUntaintCommandPlugin, Resettable { + public static String origin_repo = '' + private static DEFAULT_BRANCHES = ['master'] + private static branches = DEFAULT_BRANCHES + + public static void init() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + + BuildWithParametersPlugin.withStringParameter([ + name: "TAINT_RESOURCE", + description: 'Run `terraform taint` on the resource specified prior to planning and applying.' + ]) + + BuildWithParametersPlugin.withStringParameter([ + name: "UNTAINT_RESOURCE", + description: 'Run `terraform untaint` on the resource specified prior to planning and applying.' + ]) + + TerraformEnvironmentStage.addPlugin(plugin) + TerraformTaintCommand.addPlugin(plugin) + TerraformUntaintCommand.addPlugin(plugin) + } + + public static withOriginRepo(String origin_repo) { + this.origin_repo = origin_repo + return this + } + + public static onMasterOnly() { + this.branches = ['master'] + return this + } + + public static onBranch(String branchName) { + this.branches << branchName + return this + } + + public void apply(TerraformEnvironmentStage stage) { + stage.decorate(PLAN_COMMAND, runTerraformTaintCommand(stage.getEnvironment())) + stage.decorate(PLAN_COMMAND, runTerraformUntaintCommand(stage.getEnvironment())) + } + + public void apply(TerraformTaintCommand command) { + def resource = Jenkinsfile.instance.getEnv().TAINT_RESOURCE + if (resource) { + command.withResource(resource) + } + } + + public void apply(TerraformUntaintCommand command) { + def resource = Jenkinsfile.instance.getEnv().UNTAINT_RESOURCE + if (resource) { + command.withResource(resource) + } + } + + public boolean shouldApply() { + def apply = false + def current_repo = repoSlug(Jenkinsfile.instance.getEnv().GIT_URL) + + // Check branches + if (branches.contains(Jenkinsfile.instance.getEnv().BRANCH_NAME)) { + apply = true + } else if (null == Jenkinsfile.instance.getEnv().BRANCH_NAME) { + apply = true + } + + // Check repo slug + if (current_repo.toLowerCase() == this.origin_repo.toLowerCase()) { + apply = apply && true + } else if (! this.origin_repo) { // We assume that no origin repo means run always + apply = apply && true + } else { // We don't have a match, so reset apply + apply = false + } + + return apply + } + + private String repoSlug(String originUrl) { + if (! originUrl.endsWith('.git')) { originUrl = "${originUrl}.git" } + def giturl = originUrl =~ /^.*@.*[:\/]([^\/]+\/.*)\.git$/ + def httpurl = originUrl =~ /^https?:\/\/[^\/]+\/([^\/]+\/[^\/]+)\.git$/ + if ( giturl.matches() ) { + return giturl[0][1] + } else if ( httpurl.matches() ) { + return httpurl[0][1] + } + return "" + } + + public Closure runTerraformTaintCommand(String environment) { + def taintCommand = TerraformTaintCommand.instanceFor(environment) + return { closure -> + if (taintCommand.resource && shouldApply()) { + echo "Running '${taintCommand.toString()}'. TerraformTaintPlugin is enabled." + sh taintCommand.toString() + } + closure() + } + } + + public Closure runTerraformUntaintCommand(String environment) { + def untaintCommand = TerraformUntaintCommand.instanceFor(environment) + return { closure -> + if (untaintCommand.resource && shouldApply()) { + echo "Running '${untaintCommand.toString()}'. TerraformTaintPlugin is enabled." + sh untaintCommand.toString() + } + closure() + } + } + + public static reset() { + this.origin_repo = '' + this.branches = DEFAULT_BRANCHES.clone() + } +} diff --git a/src/TerraformUntaintCommand.groovy b/src/TerraformUntaintCommand.groovy index fb2ce95f1..18816f36f 100644 --- a/src/TerraformUntaintCommand.groovy +++ b/src/TerraformUntaintCommand.groovy @@ -1,37 +1,41 @@ class TerraformUntaintCommand implements TerraformCommand, Pluggable, Resettable { - private String command = "untaint" - private String resource + private String command = "untaint" + private String resource - public TerraformUntaintCommand(String environment) { - this.environment = environment - } + public TerraformUntaintCommand(String environment) { + this.environment = environment + } + + public TerraformUntaintCommand withResource(String resource) { + this.resource = resource - public TerraformUntaintCommand withResource(String resource) { - this.resource = resource + return this + } - return this - } + public String assembleCommandString() { + if (resource) { + def parts = [] + parts << terraformBinary + parts << command + parts << resource - public String assembleCommandString() { - if (resource) { - def parts = [] - parts << terraformBinary - parts << command - parts << resource + parts.removeAll { it == null } + return parts.join(' ') + } - parts.removeAll { it == null } - return parts.join(' ') + return "echo \"No resource set, skipping 'terraform untaint'." } - return "echo \"No resource set, skipping 'terraform untaint'." - } + public static reset() { + this.plugins = [] + } - public static reset() { - this.plugins = [] - } + public String getResource() { + return this.resource + } - public String getResource() { - return this.resource - } + public static TerraformUntaintCommand instanceFor(String environment) { + return new TerraformUntaintCommand(environment) + } } diff --git a/test/TerraformTaintCommandTest.groovy b/test/TerraformTaintCommandTest.groovy index 3c46f446d..cc25b82ad 100644 --- a/test/TerraformTaintCommandTest.groovy +++ b/test/TerraformTaintCommandTest.groovy @@ -1,5 +1,3 @@ -import static org.hamcrest.Matchers.containsString -import static org.hamcrest.Matchers.not import static org.hamcrest.Matchers.equalTo import static org.hamcrest.MatcherAssert.assertThat import static org.mockito.Mockito.mock diff --git a/test/TerraformTaintPluginTest.groovy b/test/TerraformTaintPluginTest.groovy new file mode 100644 index 000000000..cbcaf8756 --- /dev/null +++ b/test/TerraformTaintPluginTest.groovy @@ -0,0 +1,187 @@ +import static org.hamcrest.Matchers.hasItem +import static org.hamcrest.Matchers.instanceOf +import static org.hamcrest.Matchers.equalTo +import static org.hamcrest.MatcherAssert.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 org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ResetStaticStateExtension.class) +class TerraformTaintPluginTest { + @Nested + public class Init { + @Test + void modifiesTerraformEnvironmentStageCommand() { + TerraformTaintPlugin.init() + + Collection actualPlugins = TerraformEnvironmentStage.getPlugins() + assertThat(actualPlugins, hasItem(instanceOf(TerraformTaintPlugin.class))) + } + + @Test + void addsTaintResourceParameter() { + TerraformTaintPlugin.init() + + def parametersPlugin = new BuildWithParametersPlugin() + Collection actualParms = parametersPlugin.getBuildParameters() + + assertThat(actualParms, hasItem([ + $class: 'hudson.model.StringParameterDefinition', + name: "TAINT_RESOURCE", + defaultValue: "", + description: 'Run `terraform taint` on the resource specified prior to planning and applying.' + ])) + } + + @Test + void addsUntaintResourceParameter() { + TerraformTaintPlugin.init() + + def parametersPlugin = new BuildWithParametersPlugin() + Collection actualParms = parametersPlugin.getBuildParameters() + + assertThat(actualParms, hasItem([ + $class: 'hudson.model.StringParameterDefinition', + name: "UNTAINT_RESOURCE", + defaultValue: "", + description: 'Run `terraform untaint` on the resource specified prior to planning and applying.' + ])) + } + } + + @Nested + public class Apply { + @Test + void decoratesThePlanCommand() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + def environment = spy(new TerraformEnvironmentStage()) + plugin.apply(environment) + + verify(environment, times(2)).decorate(eq(TerraformEnvironmentStage.PLAN_COMMAND), any(Closure.class)) + } + + @Test + void skipsTaintWhenNoResource() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + def command = spy(new TerraformTaintCommand('env')) + MockJenkinsfile.withEnv([ + 'TAINT_RESOURCE': '' + ]) + plugin.apply(command) + + verify(command, times(0)).withResource() + } + + @Test + void setsTaintResource() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + def command = spy(new TerraformTaintCommand('env')) + MockJenkinsfile.withEnv([ + 'TAINT_RESOURCE': 'foo.bar' + ]) + plugin.apply(command) + + verify(command, times(1)).withResource(eq('foo.bar')) + } + + @Test + void skipsUntaintWhenNoResource() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + def command = spy(new TerraformUntaintCommand('env')) + MockJenkinsfile.withEnv([ + 'UNTAINT_RESOURCE': '' + ]) + plugin.apply(command) + + verify(command, times(0)).withResource() + } + + @Test + void setsUntaintResource() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + def command = spy(new TerraformUntaintCommand('env')) + MockJenkinsfile.withEnv([ + 'UNTAINT_RESOURCE': 'foo.bar' + ]) + plugin.apply(command) + + verify(command, times(1)).withResource(eq('foo.bar')) + } + } + + @Nested + public class ShouldApply { + @Test + void returnsTrueWhenNoOriginRepoSet() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(true)) + } + + @Test + void returnsFalseWhenOriginRepoMismatch() { + TerraformTaintPlugin.withOriginRepo('username/repo') + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/fork/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(false)) + } + + @Test + void returnsTrueWhenOriginRepoMatches() { + TerraformTaintPlugin.withOriginRepo('username/repo') + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(true)) + } + + @Test + void returnsFalseWhenWrongBranch() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'notmaster', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(false)) + } + + @Test + void usesMasterAsDefaultBranch() { + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(true)) + } + + @Test + void returnsTrueWhenOnCorrectCustomBranch() { + TerraformTaintPlugin.onBranch("main") + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'main', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(true)) + } + + @Test + void worksWithMultipleCustomBranches() { + TerraformTaintPlugin.onBranch("main").onBranch("notmaster") + TerraformTaintPlugin plugin = new TerraformTaintPlugin() + MockJenkinsfile.withEnv(['BRANCH_NAME': 'notmaster', 'GIT_URL': 'https://git.foo/username/repo']) + + def result = plugin.shouldApply() + assertThat(result, equalTo(true)) + } + } +} diff --git a/test/TerraformUntaintCommandTest.groovy b/test/TerraformUntaintCommandTest.groovy index e4ef9f501..3ef68897b 100644 --- a/test/TerraformUntaintCommandTest.groovy +++ b/test/TerraformUntaintCommandTest.groovy @@ -1,5 +1,3 @@ -import static org.hamcrest.Matchers.containsString -import static org.hamcrest.Matchers.not import static org.hamcrest.Matchers.equalTo import static org.hamcrest.MatcherAssert.assertThat import static org.mockito.Mockito.mock From 54fc8dbe35d842ccb58207634c70b292018828e1 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Wed, 24 Mar 2021 14:10:58 -0400 Subject: [PATCH 08/13] Update taint and untaint commands to use new Pluggable interface --- src/TerraformTaintCommand.groovy | 16 +++++++++------- src/TerraformUntaintCommand.groovy | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 2df6bd86e..8288c7ea4 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -1,6 +1,7 @@ -class TerraformTaintCommand implements TerraformCommand, Pluggable, Resettable { +class TerraformTaintCommand implements TerraformCommand, Pluggable { private String command = "taint" private String resource + private String environment public TerraformTaintCommand(String environment) { this.environment = environment @@ -12,10 +13,11 @@ class TerraformTaintCommand implements TerraformCommand, Pluggable, Resettable { +class TerraformUntaintCommand implements TerraformCommand, Pluggable { private String command = "untaint" private String resource + private String environment public TerraformUntaintCommand(String environment) { this.environment = environment @@ -12,10 +13,11 @@ class TerraformUntaintCommand implements TerraformCommand, Pluggable Date: Wed, 24 Mar 2021 14:27:32 -0400 Subject: [PATCH 09/13] Add docs for TerraformTaintPlugin --- docs/TerraformTaintPlugin.md | 46 ++++++++++++++++++++++++++++ src/TerraformTaintPlugin.groovy | 2 +- test/TerraformTaintPluginTest.groovy | 4 +-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 docs/TerraformTaintPlugin.md diff --git a/docs/TerraformTaintPlugin.md b/docs/TerraformTaintPlugin.md new file mode 100644 index 000000000..d11d08fd5 --- /dev/null +++ b/docs/TerraformTaintPlugin.md @@ -0,0 +1,46 @@ +## [TerraformTaintPlugin](../src/TerraformTaintPlugin.groovy) + +Enable this plugin to add `TAINT_RESOURCE` and `UNTAINT_RESOURCE` parameters +to the build. If a resource path is provided via one of those parameters, then +the `terraform plan` command will be preceded by the corresponding Terraform +command to taint or untaint the appropriate resources. + +Note that the untaint command will take precedence, so if for some reason the +same resource is placed in both parameters, it will be tainted and immediately +untainted, resulting in no change. + +There are several ways to customize where and when the taint/untaint can run: + +* `onlyOnOriginRepo()`: This method takes in a string of the form + `/`. When specified, the plugin will only apply when + the current checkout is from the same repository and not a fork or other + clone. +* `onMasterOnly()`: This takes no arguments. This restricts the plugin from + applying to any branch other than master. This is the default behavior. +* `onBranch()`: This takes in a branch name as a parameter. This adds the + branch to the list of approved branches. + +``` +// Jenkinsfile +@Library(['terraform-pipeline@v3.10']) _ + +Jenkinsfile.init(this, env) + +// This enables the "taint" and "untaint" functionality +// It will only apply to the master branch (default behavior) +// and will only apply if the current worspace code is checked out from the +// MyOrg/myrepo repository. +TerraformTaintPlugin.init() +TerraformTaintPlugin.onlyOnOriginRepo("MyOrg/myrepo") + +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() +``` diff --git a/src/TerraformTaintPlugin.groovy b/src/TerraformTaintPlugin.groovy index bc320c901..0c708ff5d 100644 --- a/src/TerraformTaintPlugin.groovy +++ b/src/TerraformTaintPlugin.groovy @@ -23,7 +23,7 @@ class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, Terraform TerraformUntaintCommand.addPlugin(plugin) } - public static withOriginRepo(String origin_repo) { + public static onlyOnOriginRepo(String origin_repo) { this.origin_repo = origin_repo return this } diff --git a/test/TerraformTaintPluginTest.groovy b/test/TerraformTaintPluginTest.groovy index cbcaf8756..ca0e67349 100644 --- a/test/TerraformTaintPluginTest.groovy +++ b/test/TerraformTaintPluginTest.groovy @@ -128,7 +128,7 @@ class TerraformTaintPluginTest { @Test void returnsFalseWhenOriginRepoMismatch() { - TerraformTaintPlugin.withOriginRepo('username/repo') + TerraformTaintPlugin.onlyOnOriginRepo('username/repo') TerraformTaintPlugin plugin = new TerraformTaintPlugin() MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/fork/repo']) @@ -138,7 +138,7 @@ class TerraformTaintPluginTest { @Test void returnsTrueWhenOriginRepoMatches() { - TerraformTaintPlugin.withOriginRepo('username/repo') + TerraformTaintPlugin.onlyOnOriginRepo('username/repo') TerraformTaintPlugin plugin = new TerraformTaintPlugin() MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) From f8e1471b901ed1c8d24e38f9845aedeaedc5dfa6 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Wed, 24 Mar 2021 15:22:55 -0400 Subject: [PATCH 10/13] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c6077376..05dc37107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * [Issue #347](https://github.com/manheim/terraform-pipeline/pull/348) Feature: TerraformOutputOnlyPlugin - can restrict a pipeline run to displaying the current state outputs only via new job parameters. * [Issue #318](https://github.com/manheim/terraform-pipeline/issues/318) Feature: Show environment on confirm command page * [Issue #349](https://github.com/manheim/terraform-pipeline/pull/349) Extract plugin handling to a `Pluggable` trait to DRY up the existing TerraformCommand classes. +* [Issue #354](https://github.com/manheim/terraform-pipeline/issues/354) Feature: Add TerraformTaintPlugin - Allows performing `terraform taint` or `terraform untaint` prior to the plan phase. # v5.15 From 48ce66e66b3b7b225833cc70623043000c87b26e Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Mon, 29 Mar 2021 14:21:11 -0400 Subject: [PATCH 11/13] Remove onOriginRepo() and onMasterOnly() methods from TerraformTaintPlugin --- docs/TerraformTaintPlugin.md | 7 ----- src/TerraformTaintPlugin.groovy | 42 ++-------------------------- test/TerraformTaintPluginTest.groovy | 29 ------------------- 3 files changed, 3 insertions(+), 75 deletions(-) diff --git a/docs/TerraformTaintPlugin.md b/docs/TerraformTaintPlugin.md index d11d08fd5..96cda3d52 100644 --- a/docs/TerraformTaintPlugin.md +++ b/docs/TerraformTaintPlugin.md @@ -11,12 +11,6 @@ untainted, resulting in no change. There are several ways to customize where and when the taint/untaint can run: -* `onlyOnOriginRepo()`: This method takes in a string of the form - `/`. When specified, the plugin will only apply when - the current checkout is from the same repository and not a fork or other - clone. -* `onMasterOnly()`: This takes no arguments. This restricts the plugin from - applying to any branch other than master. This is the default behavior. * `onBranch()`: This takes in a branch name as a parameter. This adds the branch to the list of approved branches. @@ -31,7 +25,6 @@ Jenkinsfile.init(this, env) // and will only apply if the current worspace code is checked out from the // MyOrg/myrepo repository. TerraformTaintPlugin.init() -TerraformTaintPlugin.onlyOnOriginRepo("MyOrg/myrepo") def validate = new TerraformValidateStage() diff --git a/src/TerraformTaintPlugin.groovy b/src/TerraformTaintPlugin.groovy index 0c708ff5d..2998d7e20 100644 --- a/src/TerraformTaintPlugin.groovy +++ b/src/TerraformTaintPlugin.groovy @@ -1,7 +1,6 @@ import static TerraformEnvironmentStage.PLAN_COMMAND class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, TerraformTaintCommandPlugin, TerraformUntaintCommandPlugin, Resettable { - public static String origin_repo = '' private static DEFAULT_BRANCHES = ['master'] private static branches = DEFAULT_BRANCHES @@ -23,16 +22,6 @@ class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, Terraform TerraformUntaintCommand.addPlugin(plugin) } - public static onlyOnOriginRepo(String origin_repo) { - this.origin_repo = origin_repo - return this - } - - public static onMasterOnly() { - this.branches = ['master'] - return this - } - public static onBranch(String branchName) { this.branches << branchName return this @@ -58,38 +47,14 @@ class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, Terraform } public boolean shouldApply() { - def apply = false - def current_repo = repoSlug(Jenkinsfile.instance.getEnv().GIT_URL) - // Check branches if (branches.contains(Jenkinsfile.instance.getEnv().BRANCH_NAME)) { - apply = true + return true } else if (null == Jenkinsfile.instance.getEnv().BRANCH_NAME) { - apply = true - } - - // Check repo slug - if (current_repo.toLowerCase() == this.origin_repo.toLowerCase()) { - apply = apply && true - } else if (! this.origin_repo) { // We assume that no origin repo means run always - apply = apply && true - } else { // We don't have a match, so reset apply - apply = false + return true } - return apply - } - - private String repoSlug(String originUrl) { - if (! originUrl.endsWith('.git')) { originUrl = "${originUrl}.git" } - def giturl = originUrl =~ /^.*@.*[:\/]([^\/]+\/.*)\.git$/ - def httpurl = originUrl =~ /^https?:\/\/[^\/]+\/([^\/]+\/[^\/]+)\.git$/ - if ( giturl.matches() ) { - return giturl[0][1] - } else if ( httpurl.matches() ) { - return httpurl[0][1] - } - return "" + return false } public Closure runTerraformTaintCommand(String environment) { @@ -115,7 +80,6 @@ class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, Terraform } public static reset() { - this.origin_repo = '' this.branches = DEFAULT_BRANCHES.clone() } } diff --git a/test/TerraformTaintPluginTest.groovy b/test/TerraformTaintPluginTest.groovy index ca0e67349..f310eed71 100644 --- a/test/TerraformTaintPluginTest.groovy +++ b/test/TerraformTaintPluginTest.groovy @@ -117,35 +117,6 @@ class TerraformTaintPluginTest { @Nested public class ShouldApply { - @Test - void returnsTrueWhenNoOriginRepoSet() { - TerraformTaintPlugin plugin = new TerraformTaintPlugin() - MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) - - def result = plugin.shouldApply() - assertThat(result, equalTo(true)) - } - - @Test - void returnsFalseWhenOriginRepoMismatch() { - TerraformTaintPlugin.onlyOnOriginRepo('username/repo') - TerraformTaintPlugin plugin = new TerraformTaintPlugin() - MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/fork/repo']) - - def result = plugin.shouldApply() - assertThat(result, equalTo(false)) - } - - @Test - void returnsTrueWhenOriginRepoMatches() { - TerraformTaintPlugin.onlyOnOriginRepo('username/repo') - TerraformTaintPlugin plugin = new TerraformTaintPlugin() - MockJenkinsfile.withEnv(['BRANCH_NAME': 'master', 'GIT_URL': 'https://git.foo/username/repo']) - - def result = plugin.shouldApply() - assertThat(result, equalTo(true)) - } - @Test void returnsFalseWhenWrongBranch() { TerraformTaintPlugin plugin = new TerraformTaintPlugin() From 755a8422b6e54b7f10230aefab51aef0278b05c5 Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Tue, 30 Mar 2021 11:42:16 -0400 Subject: [PATCH 12/13] Remove outdated mention of origin repo in documentation --- docs/TerraformTaintPlugin.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/TerraformTaintPlugin.md b/docs/TerraformTaintPlugin.md index 96cda3d52..ff670cc3f 100644 --- a/docs/TerraformTaintPlugin.md +++ b/docs/TerraformTaintPlugin.md @@ -22,8 +22,6 @@ Jenkinsfile.init(this, env) // This enables the "taint" and "untaint" functionality // It will only apply to the master branch (default behavior) -// and will only apply if the current worspace code is checked out from the -// MyOrg/myrepo repository. TerraformTaintPlugin.init() def validate = new TerraformValidateStage() From a7ad26305052e87fb5caaad053b51571394e781a Mon Sep 17 00:00:00 2001 From: Patrick Aikens Date: Tue, 30 Mar 2021 11:50:14 -0400 Subject: [PATCH 13/13] Remove unset resource guardrail from Taint and Untaint commands --- src/TerraformTaintCommand.groovy | 17 ++++++----------- src/TerraformTaintPlugin.groovy | 4 ++-- src/TerraformUntaintCommand.groovy | 17 ++++++----------- test/TerraformTaintCommandTest.groovy | 2 +- test/TerraformUntaintCommandTest.groovy | 2 +- 5 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy index 8288c7ea4..b056b8b0c 100644 --- a/src/TerraformTaintCommand.groovy +++ b/src/TerraformTaintCommand.groovy @@ -9,23 +9,18 @@ class TerraformTaintCommand implements TerraformCommand, Pluggable - if (taintCommand.resource && shouldApply()) { + if (shouldApply()) { echo "Running '${taintCommand.toString()}'. TerraformTaintPlugin is enabled." sh taintCommand.toString() } @@ -71,7 +71,7 @@ class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, Terraform public Closure runTerraformUntaintCommand(String environment) { def untaintCommand = TerraformUntaintCommand.instanceFor(environment) return { closure -> - if (untaintCommand.resource && shouldApply()) { + if (shouldApply()) { echo "Running '${untaintCommand.toString()}'. TerraformTaintPlugin is enabled." sh untaintCommand.toString() } diff --git a/src/TerraformUntaintCommand.groovy b/src/TerraformUntaintCommand.groovy index 17bc96991..9bc39ebe6 100644 --- a/src/TerraformUntaintCommand.groovy +++ b/src/TerraformUntaintCommand.groovy @@ -9,23 +9,18 @@ class TerraformUntaintCommand implements TerraformCommand, Pluggable