Skip to content

Commit

Permalink
feat(terraform): add support for workspaces
Browse files Browse the repository at this point in the history
You can now set the `workspace` field on the Terraform provider as well
as on `terraform` modules. When set, Garden will select (creating if
necessary) the specified Terraform workspace ahead of applying,
planning, destroying etc.

Closes #2024
  • Loading branch information
edvald committed Nov 30, 2020
1 parent 8059741 commit 23975f4
Show file tree
Hide file tree
Showing 13 changed files with 494 additions and 75 deletions.
2 changes: 1 addition & 1 deletion core/src/garden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ export class Garden {
}

if (this.resolvedProviders[name]) {
return this.resolvedProviders[name]
return cloneDeep(this.resolvedProviders[name])
}

const providers = await this.resolveProviders(log, false, [name])
Expand Down
12 changes: 9 additions & 3 deletions core/src/plugins/terraform/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import chalk from "chalk"
import { terraform } from "./cli"
import { TerraformProvider } from "./terraform"
import { ConfigurationError, ParameterError } from "../../exceptions"
import { prepareVariables, tfValidate } from "./common"
import { prepareVariables, tfValidate, setWorkspace } from "./common"
import { GardenModule } from "../../types/module"
import { findByName } from "../../util/util"
import { TerraformModule } from "./module"
Expand Down Expand Up @@ -52,10 +52,13 @@ function makeRootCommand(commandName: string) {
await remove(cachePath)

const root = join(ctx.projectRoot, provider.config.initRoot)
const workspace = provider.config.workspace || null

await tfValidate({ log, ctx, provider, root })
await setWorkspace({ ctx, provider, root, log, workspace })
await tfValidate({ ctx, provider, root, log })

args = [commandName, ...(await prepareVariables(root, provider.config.variables)), ...args]

await terraform(ctx, provider).spawnAndWait({
log,
args,
Expand Down Expand Up @@ -87,7 +90,10 @@ function makeModuleCommand(commandName: string) {
const root = join(module.path, module.spec.root)

const provider = ctx.provider as TerraformProvider
await tfValidate({ log, ctx, provider, root })
const workspace = module.spec.workspace || null

await setWorkspace({ ctx, provider, root, log, workspace })
await tfValidate({ ctx, provider, root, log })

args = [commandName, ...(await prepareVariables(root, module.spec.variables)), ...args.slice(1)]
await terraform(ctx, provider).spawnAndWait({
Expand Down
133 changes: 86 additions & 47 deletions core/src/plugins/terraform/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,29 @@ export interface TerraformBaseSpec {
dependencies: string[]
variables: PrimitiveMap
version: string | null
workspace?: string
}

export async function tfValidate({
log,
ctx,
provider,
root,
}: {
log: LogEntry
interface TerraformParams {
ctx: PluginContext
log: LogEntry
provider: TerraformProvider
root: string
}) {
const args = ["validate", "-json"]
}

interface TerraformParamsWithWorkspace extends TerraformParams {
workspace: string | null
}

/**
* Validates the stack at the given root.
*
* Note that this does not set the workspace, so it must be set ahead of calling the function.
*/
export async function tfValidate(params: TerraformParams) {
const { log, ctx, provider, root } = params

const args = ["validate", "-json"]
const res = await terraform(ctx, provider).json({
log,
args,
Expand Down Expand Up @@ -77,21 +85,18 @@ export async function tfValidate({
}
}

export async function getTfOutputs({
log,
ctx,
provider,
workingDir,
}: {
log: LogEntry
ctx: PluginContext
provider: TerraformProvider
workingDir: string
}) {
/**
* Returns the output from the Terraform stack.
*
* Note that this does not set the workspace, so it must be set ahead of calling the function.
*/
export async function getTfOutputs(params: TerraformParams) {
const { log, ctx, provider, root } = params

const res = await terraform(ctx, provider).json({
log,
args: ["output", "-json"],
cwd: workingDir,
cwd: root,
})

return mapValues(res, (v: any) => v.value)
Expand All @@ -108,11 +113,7 @@ export function tfValidationError(result: any) {
})
}

interface GetTerraformStackStatusParams {
ctx: PluginContext
log: LogEntry
provider: TerraformProvider
root: string
interface TerraformParamsWithVariables extends TerraformParamsWithWorkspace {
variables: object
}

Expand All @@ -125,14 +126,11 @@ type StackStatus = "up-to-date" | "outdated" | "error"
* since the user may want to manually update their stacks. The `autoApply` flag is only for information, and setting
* it to `true` does _not_ mean this method will apply the change.
*/
export async function getStackStatus({
ctx,
log,
provider,
root,
variables,
}: GetTerraformStackStatusParams): Promise<StackStatus> {
await tfValidate({ log, ctx, provider, root })
export async function getStackStatus(params: TerraformParamsWithVariables): Promise<StackStatus> {
const { ctx, log, provider, root, variables } = params

await setWorkspace(params)
await tfValidate(params)

const logEntry = log.verbose({ section: "terraform", msg: "Running plan...", status: "active" })

Expand Down Expand Up @@ -175,21 +173,12 @@ export async function getStackStatus({
}
}

export async function applyStack({
ctx,
log,
provider,
root,
variables,
}: {
ctx: PluginContext
log: LogEntry
provider: TerraformProvider
root: string
variables: object
}) {
export async function applyStack(params: TerraformParamsWithVariables) {
const { ctx, log, provider, root, variables } = params
const args = ["apply", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))]

await setWorkspace(params)

const proc = await terraform(ctx, provider).spawn({ log, args, cwd: root })

const statusLine = log.info("→ Applying Terraform stack...")
Expand Down Expand Up @@ -248,3 +237,53 @@ export async function prepareVariables(targetDir: string, variables?: object): P

return ["-var-file", path]
}

/**
* Lists the created workspaces for the given Terraform `root`, and returns which one is selected.
*/
export async function getWorkspaces({ ctx, log, provider, root }: TerraformParams) {
const res = await terraform(ctx, provider).stdout({ args: ["workspace", "list"], cwd: root, log })
let selected = "default"

const workspaces = res
.trim()
.split("\n")
.map((line) => {
let name: string

if (line.startsWith("*")) {
name = line.trim().slice(2)
selected = name
} else {
name = line.trim()
}

return name
})

return { workspaces, selected }
}

/**
* Sets the workspace to use in the Terraform `root`, creating it if it doesn't already exist. Does nothing if
* no `workspace` is set.
*/
export async function setWorkspace(params: TerraformParamsWithWorkspace) {
const { ctx, provider, root, log, workspace } = params

if (!workspace) {
return
}

const { workspaces, selected } = await getWorkspaces(params)

if (selected === workspace) {
return
}

if (workspaces.includes(workspace)) {
await terraform(ctx, provider).stdout({ args: ["workspace", "select", workspace], cwd: root, log })
} else {
await terraform(ctx, provider).stdout({ args: ["workspace", "new", workspace], cwd: root, log })
}
}
17 changes: 11 additions & 6 deletions core/src/plugins/terraform/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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, prepareVariables } from "./common"
import { getRoot, getTfOutputs, getStackStatus, applyStack, prepareVariables, setWorkspace } from "./common"
import chalk from "chalk"
import { deline } from "../../util/string"
import { CleanupEnvironmentResult, CleanupEnvironmentParams } from "../../types/plugin/provider/cleanupEnvironment"
Expand All @@ -26,11 +26,12 @@ export async function getEnvironmentStatus({ ctx, log }: GetEnvironmentStatusPar
const autoApply = provider.config.autoApply
const root = getRoot(ctx, provider)
const variables = provider.config.variables
const workspace = provider.config.workspace || null

const status = await getStackStatus({ log, ctx, provider, root, variables })
const status = await getStackStatus({ log, ctx, provider, root, variables, workspace })

if (status === "up-to-date") {
const outputs = await getTfOutputs({ log, ctx, provider, workingDir: root })
const outputs = await getTfOutputs({ log, ctx, provider, root })
return { ready: true, outputs }
} else if (status === "outdated") {
if (autoApply) {
Expand All @@ -43,7 +44,7 @@ export async function getEnvironmentStatus({ ctx, log }: GetEnvironmentStatusPar
${chalk.white.bold("garden plugins terraform apply-root")} to make sure the stack is in the intended state.
`),
})
const outputs = await getTfOutputs({ log, ctx, provider, workingDir: root })
const outputs = await getTfOutputs({ log, ctx, provider, root })
// Make sure the status is not cached when the stack is not up-to-date
return { ready: true, outputs, disableCache: true }
}
Expand All @@ -61,13 +62,14 @@ export async function prepareEnvironment({ ctx, log }: PrepareEnvironmentParams)
}

const root = getRoot(ctx, provider)
const workspace = provider.config.workspace || null

// Don't run apply when running plugin commands
if (provider.config.autoApply && !(ctx.command?.name === "plugins" && ctx.command?.args.plugin === provider.name)) {
await applyStack({ ctx, log, provider, root, variables: provider.config.variables })
await applyStack({ ctx, log, provider, root, variables: provider.config.variables, workspace })
}

const outputs = await getTfOutputs({ log, ctx, provider, workingDir: root })
const outputs = await getTfOutputs({ log, ctx, provider, root })

return {
status: {
Expand Down Expand Up @@ -95,6 +97,9 @@ export async function cleanupEnvironment({ ctx, log }: CleanupEnvironmentParams)

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

await setWorkspace({ ctx, provider, root, log, workspace })

const args = ["destroy", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))]
await terraform(ctx, provider).exec({ log, args, cwd: root })
Expand Down
16 changes: 13 additions & 3 deletions core/src/plugins/terraform/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TerraformBaseSpec,
getTfOutputs,
prepareVariables,
setWorkspace,
} from "./common"
import { TerraformProvider } from "./terraform"
import { ServiceStatus } from "../../types/service"
Expand Down Expand Up @@ -66,6 +67,7 @@ export const schema = joi.object().keys({
The version of Terraform to use. Defaults to the version set in the provider config.
Set to \`null\` to use whichever version of \`terraform\` that is on your PATH.
`),
workspace: joi.string().allow(null).description("Use the specified Terraform workspace."),
})

export async function configureTerraformModule({ ctx, moduleConfig }: ConfigureModuleParams<TerraformModule>) {
Expand Down Expand Up @@ -113,18 +115,21 @@ export async function getTerraformStatus({
const provider = ctx.provider as TerraformProvider
const root = getModuleStackRoot(module)
const variables = module.spec.variables
const workspace = module.spec.workspace || null

const status = await getStackStatus({
ctx,
log,
provider,
root,
variables,
workspace,
})

return {
state: status === "up-to-date" ? "ready" : "outdated",
version: module.version.versionString,
outputs: await getTfOutputs({ log, ctx, provider, workingDir: root }),
outputs: await getTfOutputs({ log, ctx, provider, root }),
detail: {},
}
}
Expand All @@ -135,10 +140,11 @@ export async function deployTerraform({
module,
}: DeployServiceParams<TerraformModule>): Promise<ServiceStatus> {
const provider = ctx.provider as TerraformProvider
const workspace = module.spec.workspace || null
const root = getModuleStackRoot(module)

if (module.spec.autoApply) {
await applyStack({ log, ctx, provider, root, variables: module.spec.variables })
await applyStack({ log, ctx, provider, root, variables: module.spec.variables, workspace })
} else {
const templateKey = `\${runtime.services.${module.name}.outputs.*}`
log.warn(
Expand All @@ -150,12 +156,13 @@ export async function deployTerraform({
`
)
)
await setWorkspace({ log, ctx, provider, root, workspace })
}

return {
state: "ready",
version: module.version.versionString,
outputs: await getTfOutputs({ log, ctx, provider, workingDir: root }),
outputs: await getTfOutputs({ log, ctx, provider, root }),
detail: {},
}
}
Expand All @@ -177,6 +184,9 @@ export async function deleteTerraformModule({

const root = getModuleStackRoot(module)
const variables = module.spec.variables
const workspace = module.spec.workspace || null

await setWorkspace({ ctx, provider, root, log, workspace })

const args = ["destroy", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))]
await terraform(ctx, provider).exec({ log, args, cwd: root })
Expand Down
1 change: 1 addition & 0 deletions core/src/plugins/terraform/terraform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const configSchema = providerConfigBaseSchema()
.default(defaultTerraformVersion).description(dedent`
The version of Terraform to use. Set to \`null\` to use whichever version of \`terraform\` that is on your PATH.
`),
workspace: joi.string().description("Use the specified Terraform workspace."),
})
.unknown(false)

Expand Down
5 changes: 5 additions & 0 deletions core/test/data/test-projects/terraform-module/garden.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
kind: Project
name: terraform-provider
environments:
- name: local
providers:
- name: terraform
variables:
my-variable: base
- name: test-plugin
variables:
workspace: default
---
kind: Module
type: terraform
name: tf
include: ["*"]
autoApply: true
root: ./tf
workspace: ${var.workspace}
variables:
my-variable: foo
---
Expand Down
Loading

0 comments on commit 23975f4

Please sign in to comment.