diff --git a/core/src/plugins/terraform/commands.ts b/core/src/plugins/terraform/commands.ts index eb14cdf100..869f56547d 100644 --- a/core/src/plugins/terraform/commands.ts +++ b/core/src/plugins/terraform/commands.ts @@ -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) => [ diff --git a/core/src/plugins/terraform/common.ts b/core/src/plugins/terraform/common.ts index 6bf86f697c..d4d8a9ea36 100644 --- a/core/src/plugins/terraform/common.ts +++ b/core/src/plugins/terraform/common.ts @@ -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 diff --git a/core/src/plugins/terraform/init.ts b/core/src/plugins/terraform/init.ts index 1dcced5aee..1f030165ad 100644 --- a/core/src/plugins/terraform/init.ts +++ b/core/src/plugins/terraform/init.ts @@ -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 { const provider = ctx.provider as TerraformProvider @@ -74,3 +76,28 @@ export async function prepareEnvironment({ ctx, log }: PrepareEnvironmentParams) }, } } + +export async function cleanupEnvironment({ ctx, log }: CleanupEnvironmentParams): Promise { + 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 {} +} diff --git a/core/src/plugins/terraform/module.ts b/core/src/plugins/terraform/module.ts index 24778036e9..33494ebf0b 100644 --- a/core/src/plugins/terraform/module.ts +++ b/core/src/plugins/terraform/module.ts @@ -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 @@ -31,6 +39,9 @@ export interface TerraformModule extends GardenModule {} 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 \`. + `), autoApply: joi .boolean() .allow(null) @@ -154,6 +165,35 @@ export async function deployTerraform({ } } +export async function deleteTerraformModule({ + ctx, + log, + module, +}: DeleteServiceParams): Promise { + 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) } diff --git a/core/src/plugins/terraform/terraform.ts b/core/src/plugins/terraform/terraform.ts index 7c93aa349d..4a87384ac6 100644 --- a/core/src/plugins/terraform/terraform.ts +++ b/core/src/plugins/terraform/terraform.ts @@ -9,7 +9,7 @@ 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" @@ -17,7 +17,7 @@ import { supportedVersions, defaultTerraformVersion, terraformCliSpecs } from ". 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" @@ -32,6 +32,9 @@ export interface TerraformProvider extends Provider {} 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. @@ -72,6 +75,7 @@ export const gardenPlugin = createGardenPlugin({ configureProvider, getEnvironmentStatus, prepareEnvironment, + cleanupEnvironment, }, commands: terraformCommands, createModuleTypes: [ @@ -114,6 +118,7 @@ export const gardenPlugin = createGardenPlugin({ configure: configureTerraformModule, getServiceStatus: getTerraformStatus, deployService: deployTerraform, + deleteService: deleteTerraformModule, }, }, ], diff --git a/core/test/data/test-projects/terraform-provider/garden.yml b/core/test/data/test-projects/terraform-provider/garden.yml index 1dc27338b6..32db96f10b 100644 --- a/core/test/data/test-projects/terraform-provider/garden.yml +++ b/core/test/data/test-projects/terraform-provider/garden.yml @@ -5,6 +5,7 @@ environments: - name: prod providers: - name: terraform + allowDestroy: ${environment.name != 'prod'} autoApply: ${environment.name != 'prod'} initRoot: tf variables: diff --git a/core/test/unit/src/plugins/terraform/terraform.ts b/core/test/unit/src/plugins/terraform/terraform.ts index 8030dcbf01..eac7e0dba0 100644 --- a/core/test/unit/src/plugins/terraform/terraform.ts +++ b/core/test/unit/src/plugins/terraform/terraform.ts @@ -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) @@ -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) @@ -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", () => { @@ -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 + }) + }) }) }) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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")! @@ -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 + }) + }) }) diff --git a/docs/reference/module-types/terraform.md b/docs/reference/module-types/terraform.md index 87056d6567..b6a4c001f5 100644 --- a/docs/reference/module-types/terraform.md +++ b/docs/reference/module-types/terraform.md @@ -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 `. +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. @@ -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 `. + +| Type | Default | Required | +| --------- | ------- | -------- | +| `boolean` | `false` | No | + ### `autoApply` If set to true, Garden will automatically run `terraform apply -auto-approve` when the stack is not diff --git a/docs/reference/providers/container.md b/docs/reference/providers/container.md index 65c9eb86b5..837b2908da 100644 --- a/docs/reference/providers/container.md +++ b/docs/reference/providers/container.md @@ -23,6 +23,9 @@ providers: - # The name of the provider plugin to use. name: + # List other providers that should be resolved before this one. + dependencies: [] + # If specified, this provider will only be used in the listed environments. Note that an empty array effectively # disables the provider. To use a provider in all environments, omit this field. environments: @@ -52,6 +55,24 @@ providers: - name: "local-kubernetes" ``` +### `providers[].dependencies[]` + +[providers](#providers) > dependencies + +List other providers that should be resolved before this one. + +| Type | Default | Required | +| --------------- | ------- | -------- | +| `array[string]` | `[]` | No | + +Example: + +```yaml +providers: + - dependencies: + - exec +``` + ### `providers[].environments[]` [providers](#providers) > environments diff --git a/docs/reference/providers/terraform.md b/docs/reference/providers/terraform.md index 9f0a8c05fb..2a22eee4c5 100644 --- a/docs/reference/providers/terraform.md +++ b/docs/reference/providers/terraform.md @@ -29,6 +29,9 @@ providers: # disables the provider. To use a provider in all environments, omit this field. environments: + # If set to true, Garden will run `terraform destroy` on the project root stack when calling `garden delete env`. + allowDestroy: false + # 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. # @@ -110,6 +113,16 @@ providers: - stage ``` +### `providers[].allowDestroy` + +[providers](#providers) > allowDestroy + +If set to true, Garden will run `terraform destroy` on the project root stack when calling `garden delete env`. + +| Type | Default | Required | +| --------- | ------- | -------- | +| `boolean` | `false` | No | + ### `providers[].autoApply` [providers](#providers) > autoApply