Skip to content

Commit

Permalink
feat(terraform): add allowDestroy flags to automatically destroy stacks
Browse files Browse the repository at this point in the history
You can now set `allowDestroy: true` in both the provider config and
`terraform` module configs, which if set allow Garden to automatically
destroy stacks when calling `garden delete env` and/or
`garden delete service <module name>`.

Also added are new `garden plugins terraform destroy-root` and
`garden plugins terraform destroy-module` commands.

Closes #1928
  • Loading branch information
edvald committed Aug 25, 2020
1 parent f322937 commit 7d39ff2
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 12 deletions.
2 changes: 1 addition & 1 deletion core/src/plugins/terraform/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { join } from "path"
import { remove } from "fs-extra"
import { getProviderStatusCachePath } from "../../tasks/resolve-provider"

const commandsToWrap = ["apply", "plan"]
const commandsToWrap = ["apply", "plan", "destroy"]
const initCommand = chalk.bold("terraform init")

export const terraformCommands: PluginCommand[] = commandsToWrap.flatMap((commandName) => [
Expand Down
1 change: 1 addition & 0 deletions core/src/plugins/terraform/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import chalk from "chalk"
export const variablesSchema = () => joiStringMap(joi.any())

export interface TerraformBaseSpec {
allowDestroy: boolean
autoApply: boolean
dependencies: string[]
variables: PrimitiveMap
Expand Down
29 changes: 28 additions & 1 deletion core/src/plugins/terraform/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import { TerraformProvider } from "./terraform"
import { GetEnvironmentStatusParams, EnvironmentStatus } from "../../types/plugin/provider/getEnvironmentStatus"
import { PrepareEnvironmentParams, PrepareEnvironmentResult } from "../../types/plugin/provider/prepareEnvironment"
import { getRoot, getTfOutputs, getStackStatus, applyStack } from "./common"
import { getRoot, getTfOutputs, getStackStatus, applyStack, prepareVariables } from "./common"
import chalk from "chalk"
import { deline } from "../../util/string"
import { CleanupEnvironmentResult, CleanupEnvironmentParams } from "../../types/plugin/provider/cleanupEnvironment"
import { terraform } from "./cli"

export async function getEnvironmentStatus({ ctx, log }: GetEnvironmentStatusParams): Promise<EnvironmentStatus> {
const provider = ctx.provider as TerraformProvider
Expand Down Expand Up @@ -74,3 +76,28 @@ export async function prepareEnvironment({ ctx, log }: PrepareEnvironmentParams)
},
}
}

export async function cleanupEnvironment({ ctx, log }: CleanupEnvironmentParams): Promise<CleanupEnvironmentResult> {
const provider = ctx.provider as TerraformProvider

if (!provider.config.initRoot) {
// Nothing to do!
return {}
}

if (!provider.config.allowDestroy) {
log.warn({
section: provider.name,
msg: "allowDestroy is set to false. Not calling terraform destroy for root stack.",
})
return {}
}

const root = getRoot(ctx, provider)
const variables = provider.config.variables

const args = ["destroy", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))]
await terraform(ctx, provider).exec({ log, args, cwd: root })

return {}
}
44 changes: 42 additions & 2 deletions core/src/plugins/terraform/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,26 @@ import { join } from "path"
import { pathExists } from "fs-extra"
import { joi } from "../../config/common"
import { dedent, deline } from "../../util/string"
import { supportedVersions } from "./cli"
import { supportedVersions, terraform } from "./cli"
import { GardenModule } from "../../types/module"
import { ConfigureModuleParams } from "../../types/plugin/module/configure"
import { ConfigurationError } from "../../exceptions"
import { dependenciesSchema } from "../../config/service"
import { DeployServiceParams } from "../../../src/types/plugin/service/deployService"
import { GetServiceStatusParams } from "../../../src/types/plugin/service/getServiceStatus"
import { getStackStatus, applyStack, variablesSchema, TerraformBaseSpec, getTfOutputs } from "./common"
import {
getStackStatus,
applyStack,
variablesSchema,
TerraformBaseSpec,
getTfOutputs,
prepareVariables,
} from "./common"
import { TerraformProvider } from "./terraform"
import { ServiceStatus } from "../../types/service"
import { baseBuildSpecSchema } from "../../config/module"
import chalk = require("chalk")
import { DeleteServiceParams } from "../../types/plugin/service/deleteService"

export interface TerraformModuleSpec extends TerraformBaseSpec {
root: string
Expand All @@ -31,6 +39,9 @@ export interface TerraformModule extends GardenModule<TerraformModuleSpec> {}

export const schema = joi.object().keys({
build: baseBuildSpecSchema(),
allowDestroy: joi.boolean().default(false).description(dedent`
If set to true, Garden will run \`terraform destroy\` on the stack when calling \`garden delete env\` or \`garden delete service <module name>\`.
`),
autoApply: joi
.boolean()
.allow(null)
Expand Down Expand Up @@ -154,6 +165,35 @@ export async function deployTerraform({
}
}

export async function deleteTerraformModule({
ctx,
log,
module,
}: DeleteServiceParams<TerraformModule>): Promise<ServiceStatus> {
const provider = ctx.provider as TerraformProvider

if (!module.spec.allowDestroy) {
log.warn({ section: module.name, msg: "allowDestroy is set to false. Not calling terraform destroy." })
return {
state: "outdated",
detail: {},
}
}

const root = getModuleStackRoot(module)
const variables = module.spec.variables

const args = ["destroy", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))]
await terraform(ctx, provider).exec({ log, args, cwd: root })

return {
state: "missing",
version: module.version.versionString,
outputs: {},
detail: {},
}
}

function getModuleStackRoot(module: TerraformModule) {
return join(module.path, module.spec.root)
}
9 changes: 7 additions & 2 deletions core/src/plugins/terraform/terraform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import { join } from "path"
import { pathExists } from "fs-extra"
import { createGardenPlugin } from "../../types/plugin/plugin"
import { getEnvironmentStatus, prepareEnvironment } from "./init"
import { getEnvironmentStatus, prepareEnvironment, cleanupEnvironment } from "./init"
import { providerConfigBaseSchema, GenericProviderConfig, Provider } from "../../config/provider"
import { joi, joiVariables } from "../../config/common"
import { dedent } from "../../util/string"
import { supportedVersions, defaultTerraformVersion, terraformCliSpecs } from "./cli"
import { ConfigureProviderParams, ConfigureProviderResult } from "../../types/plugin/provider/configureProvider"
import { ConfigurationError } from "../../exceptions"
import { variablesSchema, TerraformBaseSpec } from "./common"
import { schema, configureTerraformModule, getTerraformStatus, deployTerraform } from "./module"
import { schema, configureTerraformModule, getTerraformStatus, deployTerraform, deleteTerraformModule } from "./module"
import { DOCS_BASE_URL } from "../../constants"
import { SuggestModulesParams, SuggestModulesResult } from "../../types/plugin/module/suggestModules"
import { listDirectory } from "../../util/fs"
Expand All @@ -32,6 +32,9 @@ export interface TerraformProvider extends Provider<TerraformProviderConfig> {}

const configSchema = providerConfigBaseSchema()
.keys({
allowDestroy: joi.boolean().default(false).description(dedent`
If set to true, Garden will run \`terraform destroy\` on the project root stack when calling \`garden delete env\`.
`),
autoApply: joi.boolean().default(false).description(dedent`
If set to true, Garden will automatically run \`terraform apply -auto-approve\` when a stack is not up-to-date. Otherwise, a warning is logged if the stack is out-of-date, and an error thrown if it is missing entirely.
Expand Down Expand Up @@ -72,6 +75,7 @@ export const gardenPlugin = createGardenPlugin({
configureProvider,
getEnvironmentStatus,
prepareEnvironment,
cleanupEnvironment,
},
commands: terraformCommands,
createModuleTypes: [
Expand Down Expand Up @@ -114,6 +118,7 @@ export const gardenPlugin = createGardenPlugin({
configure: configureTerraformModule,
getServiceStatus: getTerraformStatus,
deployService: deployTerraform,
deleteService: deleteTerraformModule,
},
},
],
Expand Down
1 change: 1 addition & 0 deletions core/test/data/test-projects/terraform-provider/garden.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ environments:
- name: prod
providers:
- name: terraform
allowDestroy: ${environment.name != 'prod'}
autoApply: ${environment.name != 'prod'}
initRoot: tf
variables:
Expand Down
107 changes: 101 additions & 6 deletions core/test/unit/src/plugins/terraform/terraform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe("Terraform provider", () => {
})

describe("apply-root command", () => {
it("call terraform apply for the project root", async () => {
it("calls terraform apply for the project root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)

Expand All @@ -94,7 +94,7 @@ describe("Terraform provider", () => {
})

describe("plan-root command", () => {
it("call terraform plan for the project root", async () => {
it("calls terraform plan for the project root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)

Expand All @@ -107,6 +107,44 @@ describe("Terraform provider", () => {
})
})
})

describe("destroy-root command", () => {
it("calls terraform destroy for the project root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)

const command = findByName(terraformCommands, "destroy-root")!
await command.handler({
ctx,
args: ["-input=false", "-auto-approve"],
log: garden.log,
modules: [],
})
})
})

context("allowDestroy=false", () => {
it("doesn't call terraform destroy when calling the delete service handler", async () => {
const provider = await garden.resolveProvider(garden.log, "terraform")
const ctx = await garden.getPluginContext(provider)

// This creates the test file
const command = findByName(terraformCommands, "apply-root")!
await command.handler({
ctx,
args: ["-auto-approve", "-input=false"],
log: garden.log,
modules: [],
})

const actions = await garden.getActionRouter()
await actions.cleanupEnvironment({ log: garden.log, pluginName: "terraform" })

// File should still exist
const testFileContent = await readFile(testFilePath)
expect(testFileContent.toString()).to.equal("foo")
})
})
})

context("autoApply=true", () => {
Expand All @@ -132,6 +170,19 @@ describe("Terraform provider", () => {
"test-file-path": "./test.log",
})
})

context("allowDestroy=true", () => {
it("calls terraform destroy when calling the delete service handler", async () => {
// This implicitly creates the test file
await garden.resolveProvider(garden.log, "terraform")

// This should remove the file
const actions = await garden.getActionRouter()
await actions.cleanupEnvironment({ log: garden.log, pluginName: "terraform" })

expect(await pathExists(testFilePath)).to.be.false
})
})
})
})

Expand Down Expand Up @@ -181,8 +232,9 @@ describe("Terraform module type", () => {
return garden.processTasks([deployTask], { throwOnError: true })
}

async function runTestTask(autoApply: boolean) {
async function runTestTask(autoApply: boolean, allowDestroy = false) {
await garden.scanAndAddConfigs()
garden["moduleConfigs"]["tf"].spec.allowDestroy = allowDestroy
garden["moduleConfigs"]["tf"].spec.autoApply = autoApply

graph = await garden.getConfigGraph(garden.log)
Expand All @@ -202,7 +254,7 @@ describe("Terraform module type", () => {
}

describe("apply-module command", () => {
it("call terraform apply for the module root", async () => {
it("calls terraform apply for the module root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)
graph = await garden.getConfigGraph(garden.log)
Expand All @@ -218,7 +270,7 @@ describe("Terraform module type", () => {
})

describe("plan-module command", () => {
it("call terraform apply for the module root", async () => {
it("calls terraform apply for the module root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)
graph = await garden.getConfigGraph(garden.log)
Expand All @@ -233,6 +285,22 @@ describe("Terraform module type", () => {
})
})

describe("destroy-module command", () => {
it("calls terraform destroy for the module root", async () => {
const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider
const ctx = await garden.getPluginContext(provider)
graph = await garden.getConfigGraph(garden.log)

const command = findByName(terraformCommands, "destroy-module")!
await command.handler({
ctx,
args: ["tf", "-input=false", "-auto-approve"],
log: garden.log,
modules: graph.getModules(),
})
})
})

context("autoApply=false", () => {
it("should warn if the stack is out of date", async () => {
await deployStack(false)
Expand All @@ -259,7 +327,7 @@ describe("Terraform module type", () => {
expect(result["task.test-task"]!.output.outputs.log).to.equal("input: foo")
})

it("should should return outputs with the service status", async () => {
it("should return outputs with the service status", async () => {
const provider = await garden.resolveProvider(garden.log, "terraform")
const ctx = await garden.getPluginContext(provider)
const applyCommand = findByName(terraformCommands, "apply-module")!
Expand Down Expand Up @@ -302,4 +370,31 @@ describe("Terraform module type", () => {
expect(result["task.test-task"]!.output.outputs.log).to.equal("input: foo")
})
})

context("allowDestroy=false", () => {
it("doesn't call terraform destroy when calling the delete service handler", async () => {
await runTestTask(true, false)

const actions = await garden.getActionRouter()
const service = graph.getService("tf")

await actions.deleteService({ service, log: garden.log })

const testFileContent = await readFile(testFilePath)
expect(testFileContent.toString()).to.equal("foo")
})
})

context("allowDestroy=true", () => {
it("calls terraform destroy when calling the delete service handler", async () => {
await runTestTask(true, true)

const actions = await garden.getActionRouter()
const service = graph.getService("tf")

await actions.deleteService({ service, log: garden.log })

expect(await pathExists(testFilePath)).to.be.false
})
})
})
12 changes: 12 additions & 0 deletions docs/reference/module-types/terraform.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ build:
# Defaults to to same as source path.
target: ''

# If set to true, Garden will run `terraform destroy` on the stack when calling `garden delete env` or `garden delete
# service <module name>`.
allowDestroy: false

# If set to true, Garden will automatically run `terraform apply -auto-approve` when the stack is not
# up-to-date. Otherwise, a warning is logged if the stack is out-of-date, and an error thrown if it is missing
# entirely.
Expand Down Expand Up @@ -332,6 +336,14 @@ Defaults to to same as source path.
| ----------- | ------- | -------- |
| `posixPath` | `""` | No |

### `allowDestroy`

If set to true, Garden will run `terraform destroy` on the stack when calling `garden delete env` or `garden delete service <module name>`.

| Type | Default | Required |
| --------- | ------- | -------- |
| `boolean` | `false` | No |

### `autoApply`

If set to true, Garden will automatically run `terraform apply -auto-approve` when the stack is not
Expand Down
Loading

0 comments on commit 7d39ff2

Please sign in to comment.