diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c607737..05dc3710 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 diff --git a/docs/TerraformTaintPlugin.md b/docs/TerraformTaintPlugin.md new file mode 100644 index 00000000..ff670cc3 --- /dev/null +++ b/docs/TerraformTaintPlugin.md @@ -0,0 +1,37 @@ +## [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: + +* `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) +TerraformTaintPlugin.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() +``` diff --git a/src/TerraformTaintCommand.groovy b/src/TerraformTaintCommand.groovy new file mode 100644 index 00000000..b056b8b0 --- /dev/null +++ b/src/TerraformTaintCommand.groovy @@ -0,0 +1,37 @@ +class TerraformTaintCommand implements TerraformCommand, Pluggable { + private String command = "taint" + private String resource + private String environment + + public TerraformTaintCommand(String environment) { + this.environment = environment + } + + public TerraformTaintCommand withResource(String resource) { + this.resource = resource + return this + } + + public String toString() { + applyPlugins() + def parts = [] + parts << 'terraform' + parts << command + parts << resource + + parts.removeAll { it == null } + return parts.join(' ') + } + + public String getResource() { + return this.resource + } + + public static TerraformTaintCommand instanceFor(String environment) { + return new TerraformTaintCommand(environment) + } + + public String getEnvironment() { + return this.environment + } +} diff --git a/src/TerraformTaintCommandPlugin.groovy b/src/TerraformTaintCommandPlugin.groovy new file mode 100644 index 00000000..24755589 --- /dev/null +++ b/src/TerraformTaintCommandPlugin.groovy @@ -0,0 +1,3 @@ +interface TerraformTaintCommandPlugin { + public void apply(TerraformTaintCommand command) +} diff --git a/src/TerraformTaintPlugin.groovy b/src/TerraformTaintPlugin.groovy new file mode 100644 index 00000000..32fe8c45 --- /dev/null +++ b/src/TerraformTaintPlugin.groovy @@ -0,0 +1,85 @@ +import static TerraformEnvironmentStage.PLAN_COMMAND + +class TerraformTaintPlugin implements TerraformEnvironmentStagePlugin, TerraformTaintCommandPlugin, TerraformUntaintCommandPlugin, Resettable { + 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 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() { + // Check branches + if (branches.contains(Jenkinsfile.instance.getEnv().BRANCH_NAME)) { + return true + } else if (null == Jenkinsfile.instance.getEnv().BRANCH_NAME) { + return true + } + + return false + } + + public Closure runTerraformTaintCommand(String environment) { + def taintCommand = TerraformTaintCommand.instanceFor(environment) + return { closure -> + if (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 (shouldApply()) { + echo "Running '${untaintCommand.toString()}'. TerraformTaintPlugin is enabled." + sh untaintCommand.toString() + } + closure() + } + } + + public static reset() { + this.branches = DEFAULT_BRANCHES.clone() + } +} diff --git a/src/TerraformUntaintCommand.groovy b/src/TerraformUntaintCommand.groovy new file mode 100644 index 00000000..9bc39ebe --- /dev/null +++ b/src/TerraformUntaintCommand.groovy @@ -0,0 +1,38 @@ +class TerraformUntaintCommand implements TerraformCommand, Pluggable { + private String command = "untaint" + private String resource + private String environment + + public TerraformUntaintCommand(String environment) { + this.environment = environment + } + + public TerraformUntaintCommand withResource(String resource) { + this.resource = resource + return this + } + + public String toString() { + applyPlugins() + def parts = [] + parts << 'terraform' + parts << command + parts << resource + + parts.removeAll { it == null } + return parts.join(' ') + } + + public String getResource() { + return this.resource + } + + public static TerraformUntaintCommand instanceFor(String environment) { + return new TerraformUntaintCommand(environment) + } + + public String getEnvironment() { + return this.environment + } +} + diff --git a/src/TerraformUntaintCommandPlugin.groovy b/src/TerraformUntaintCommandPlugin.groovy new file mode 100644 index 00000000..785c694f --- /dev/null +++ b/src/TerraformUntaintCommandPlugin.groovy @@ -0,0 +1,3 @@ +interface TerraformUntaintCommandPlugin { + public void apply(TerraformUntaintCommand command) +} diff --git a/test/TerraformTaintCommandTest.groovy b/test/TerraformTaintCommandTest.groovy new file mode 100644 index 00000000..a51aeb93 --- /dev/null +++ b/test/TerraformTaintCommandTest.groovy @@ -0,0 +1,77 @@ +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("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) + } + } +} diff --git a/test/TerraformTaintPluginTest.groovy b/test/TerraformTaintPluginTest.groovy new file mode 100644 index 00000000..f310eed7 --- /dev/null +++ b/test/TerraformTaintPluginTest.groovy @@ -0,0 +1,158 @@ +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 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 new file mode 100644 index 00000000..a4800e2a --- /dev/null +++ b/test/TerraformUntaintCommandTest.groovy @@ -0,0 +1,77 @@ +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("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) + } + } +}