Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* [Issue #363](https://github.com/manheim/terraform-pipeline/issues/363) Feature: Optionally disable echo on flyway CLI commands
* [Issue #365](https://github.com/manheim/terraform-pipeline/issues/365) Feature: Optionally configure flyway username/password through CLI options
* [Issue #362](https://github.com/manheim/terraform-pipeline/issues/362) Bug Fix: Apply AnsiColorPlugin on `terraform validate` and `terraform init`
* [Issue #368](https://github.com/manheim/terraform-pipeline/issues/368) Feature: FlywayMigrationPlugin - prompt user *again* before applying migration. Optionally disable prompt.

# v5.15

Expand Down
28 changes: 27 additions & 1 deletion docs/FlywayMigrationPlugin.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## [FlywayMigrationPlugin](../src/FlywayMigrationPlugin.groovy)

Enable this plugin to run automated database migrations with [Flyway](https://flywaydb.org/). Flyway can be configured through standard configuration files, or through environment variables.
Enable this plugin to run automated database migrations with [Flyway](https://flywaydb.org/). Flyway can be configured through standard configuration files, or through environment variables. Since migrations may happen less frequently than terrform changes, by default, if a pending migration is detected the pipeline will prompt you *again* to confirm that you want to proceed with the migration.

```
@Library(['terraform-pipeline']) _
Expand Down Expand Up @@ -73,3 +73,29 @@ validate.then(deployQa)
.build()
```

If you don't want to be prompted a second time when migrations are detected, you can disable the migration confirmation with `FlywayMigrationPlugin.confirmBeforeApplyingMigration(false)`.

```
@Library(['terraform-pipeline']) _
Jenkinsfile.init(this, Customizations)
// Disable second confirmation when pending migration is detected.
FlywayMigrationPlugin.confirmBeforeApplyingMigration(false)
.init()

Jenkinsfile.init(this, Customizations)
Jenkinsfile.defaultNodeName = 'docker'
ConditionalApplyPlugin.withApplyOnEnvironment('qa')

AgentNodePlugin.withAgentDockerImage("custom-terraform-fakedb")
.withAgentDockerfile()
.withAgentDockerImageOptions("--entrypoint=''")
.init()

def validate = new TerraformValidateStage()
def deployQa = new TerraformEnvironmentStage('qa')
def deployUat = new TerraformEnvironmentStage('uat')

validate.then(deployQa)
.then(deployUat)
.build()
```
48 changes: 47 additions & 1 deletion src/FlywayMigrationPlugin.groovy
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class FlywayMigrationPlugin implements TerraformEnvironmentStagePlugin, Resettable {
public static Map<String,String> variableMap = [:]
public static boolean echoEnabled = false
public static boolean confirmBeforeApply = true

public static void init() {
TerraformEnvironmentStage.addPlugin(new FlywayMigrationPlugin())
Expand All @@ -23,8 +24,36 @@ class FlywayMigrationPlugin implements TerraformEnvironmentStagePlugin, Resettab
}
}

public boolean hasPendingMigration(workflowScript) {
def closure = {
def resultString = sh (
script: 'set +e; grep Pending flyway_output.txt > /dev/null; if [ $? -eq 0 ]; then echo true; else echo false; fi',
returnStdout: true
).trim()
return new Boolean(resultString)
}

closure.delegate = workflowScript
return closure()
}

public void confirmMigration(workflowScript) {
def closure = {
timeout(time: 1, unit: 'MINUTES') {
input("One or more pending migrations will be applied immediately if you continue - please review the flyway info output. Are you sure you want to continue?")
}
}

closure.delegate = workflowScript
closure()
}

public Closure flywayMigrateClosure() {
return { innerClosure ->
if (confirmBeforeApply && hasPendingMigration(delegate)) {
confirmMigration(delegate)
}

innerClosure()

def environmentVariables = buildEnvironmentVariableList(env)
Expand All @@ -47,7 +76,18 @@ class FlywayMigrationPlugin implements TerraformEnvironmentStagePlugin, Resettab
if (!echoEnabled) {
pieces << 'set +x'
}
pieces << command.toString()

if (confirmBeforeApply) {
pieces << 'set -o pipefail'
}

def commandString = command.toString()
if (confirmBeforeApply) {
commandString += "| tee flyway_output.txt"
}

pieces << commandString

if (!echoEnabled) {
pieces << 'set -x'
}
Expand All @@ -65,8 +105,14 @@ class FlywayMigrationPlugin implements TerraformEnvironmentStagePlugin, Resettab
return this
}

public static confirmBeforeApplyingMigration(boolean trueOrFalse = true) {
this.confirmBeforeApply = trueOrFalse
return this
}

public static reset() {
variableMap = [:]
echoEnabled = false
confirmBeforeApply = true
}
}
189 changes: 180 additions & 9 deletions test/FlywayMigrationPluginTest.groovy
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import static org.hamcrest.Matchers.containsString
import static org.hamcrest.Matchers.endsWith
import static org.hamcrest.Matchers.equalTo
import static org.hamcrest.Matchers.hasItem
import static org.hamcrest.Matchers.instanceOf
import static org.hamcrest.Matchers.not
import static org.hamcrest.Matchers.startsWith
import static org.hamcrest.MatcherAssert.assertThat
import static org.mockito.Mockito.any
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doReturn
import static org.mockito.Mockito.eq
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mock
import static org.mockito.Mockito.spy
import static org.mockito.Mockito.times
import static org.mockito.Mockito.verify

import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -131,6 +136,81 @@ class FlywayMigrationPluginTest {

verify(mockWorkflowScript).withEnv(eq(expectedList), any(Closure.class))
}

@Test
void runsConfirmMigrationIfConfirmBeforeApplyAndHasPendingMigration() {
def plugin = spy(new FlywayMigrationPlugin())
doReturn(true).when(plugin).hasPendingMigration(any(Object.class))
FlywayMigrationPlugin.confirmBeforeApplyingMigration(true)

def flywayClosure = plugin.flywayMigrateClosure()
flywayClosure.delegate = new MockWorkflowScript()
flywayClosure { -> }

verify(plugin).confirmMigration(any(Object.class))
}

@Test
void doesNotRunConfirmMigrationIfNotConfirmBeforeApplyAndHasPendingMigration() {
def plugin = spy(new FlywayMigrationPlugin())
doReturn(true).when(plugin).hasPendingMigration(any(Object.class))
FlywayMigrationPlugin.confirmBeforeApplyingMigration(false)

def flywayClosure = plugin.flywayMigrateClosure()
flywayClosure.delegate = new MockWorkflowScript()
flywayClosure { -> }

verify(plugin, times(0)).confirmMigration(any(Object.class))
}

@Test
void doesNotRunConfirmMigrationIfConfirmBeforeApplyAndDoesNotHavePendingMigration() {
def plugin = spy(new FlywayMigrationPlugin())
doReturn(false).when(plugin).hasPendingMigration(any(Object.class))
FlywayMigrationPlugin.confirmBeforeApplyingMigration(true)

def flywayClosure = plugin.flywayMigrateClosure()
flywayClosure.delegate = new MockWorkflowScript()
flywayClosure { -> }

verify(plugin, times(0)).confirmMigration(any(Object.class))
}
}

@Nested
public class HasPendingMigration {
@Test
void returnsTrueWhenShellReturnsTrueString() {
def plugin = new FlywayMigrationPlugin()
def workflowScript = spy(new MockWorkflowScript())
doReturn('true').when(workflowScript).sh(any(Map.class))

def result = plugin.hasPendingMigration(workflowScript)

assertThat(result, equalTo(true))
}

@Test
void returnsFalseWhenShellReturnsFalseString() {
def plugin = new FlywayMigrationPlugin()
def workflowScript = spy(new MockWorkflowScript())
doReturn('false').when(workflowScript).sh(any(Map.class))

def result = plugin.hasPendingMigration(workflowScript)

assertThat(result, equalTo(false))
}

@Test
void returnsFalseWhenShellReturnsAnyOtherString() {
def plugin = new FlywayMigrationPlugin()
def workflowScript = spy(new MockWorkflowScript())
doReturn('blahblah').when(workflowScript).sh(any(Map.class))

def result = plugin.hasPendingMigration(workflowScript)

assertThat(result, equalTo(false))
}
}

@Nested
Expand Down Expand Up @@ -162,28 +242,119 @@ class FlywayMigrationPluginTest {
@Nested
public class BuildFlywayCommand {
@Test
void disablesEchoBeforeFlywayAndEnablesEchoAfterByDefault() {
void constructsTheFlywayCommand() {
def flywayCommand = 'flyway foo'
def command = mock(FlywayCommand.class)
doReturn(flywayCommand).when(command).toString()
def plugin = new FlywayMigrationPlugin()

def result = plugin.buildFlywayCommand(command)

assertThat(result, containsString(flywayCommand))
}

@Test
void disablesEchoBeforeFlywayByDefault() {
def flywayCommand = 'flyway foo'
def command = mock(FlywayCommand.class)
doReturn(flywayCommand).when(command).toString()
def plugin = new FlywayMigrationPlugin()

def result = plugin.buildFlywayCommand(command)

assertThat(result, equalTo("set +x\n${flywayCommand}\nset -x".toString()))
assertThat(result, startsWith("set +x"))
}

@Test
void returnsTheCommandIfEchoEnabled() {
void disablesEchoAfterFlywayByDefault() {
def flywayCommand = 'flyway foo'
def command = mock(FlywayCommand.class)
doReturn(flywayCommand).when(command).toString()
def plugin = new FlywayMigrationPlugin()
FlywayMigrationPlugin.withEchoEnabled()

def result = plugin.buildFlywayCommand(command)

assertThat(result, equalTo(flywayCommand))
assertThat(result, endsWith("set -x"))
}

@Test
void prefixesFlywayCommandWithPipelineFail() {
def plugin = spy(new FlywayMigrationPlugin())

def result = plugin.buildFlywayCommand(mock(FlywayCommand.class))

assertThat(result, containsString("set -o pipefail"))
}

@Test
void pipesFlywayCommandToFileWithTee() {
def plugin = spy(new FlywayMigrationPlugin())

def result = plugin.buildFlywayCommand(mock(FlywayCommand.class))

assertThat(result, containsString("| tee flyway_output.txt"))
}

@Nested
public class WithEchoEnabled {
@Test
void doesNotDisbleEchoBeforeFlyway() {
def flywayCommand = 'flyway foo'
def command = mock(FlywayCommand.class)
doReturn(flywayCommand).when(command).toString()
def plugin = new FlywayMigrationPlugin()

FlywayMigrationPlugin.withEchoEnabled()
def result = plugin.buildFlywayCommand(command)

assertThat(result, not(startsWith("set +x")))
}

@Test
void doesNotReenbleEchoAfterFlyway() {
def flywayCommand = 'flyway foo'
def command = mock(FlywayCommand.class)
doReturn(flywayCommand).when(command).toString()
def plugin = new FlywayMigrationPlugin()

FlywayMigrationPlugin.withEchoEnabled()
def result = plugin.buildFlywayCommand(command)

assertThat(result, not(endsWith("set -x")))
}
}

@Nested
public class WithConfirmBeforeApplyingMigrationDisabled {
@Test
void doesNotPrefixFlywayCommandWithPipelineFail() {
def plugin = spy(new FlywayMigrationPlugin())
FlywayMigrationPlugin.confirmBeforeApplyingMigration(false)

def result = plugin.buildFlywayCommand(mock(FlywayCommand.class))

assertThat(result, not(containsString("set -o pipefail")))
}

@Test
void doesNotPipeFlywayCommandToFileWthTee() {
def plugin = spy(new FlywayMigrationPlugin())
FlywayMigrationPlugin.confirmBeforeApplyingMigration(false)

def result = plugin.buildFlywayCommand(mock(FlywayCommand.class))

assertThat(result, not(containsString("| tee flyway_output.txt")))
}
}
}

@Nested
public class ConfirmBeforeApplyingMigration {
@Test
void isFluent() {
def result = FlywayMigrationPlugin.confirmBeforeApplyingMigration()

assertThat(result, equalTo(FlywayMigrationPlugin.class))
}
}
}
Expand Down