diff --git a/plugins/terraform/src/commands.ts b/plugins/terraform/src/commands.ts index d5f6b00659..7fcd21437e 100644 --- a/plugins/terraform/src/commands.ts +++ b/plugins/terraform/src/commands.ts @@ -9,7 +9,7 @@ import { terraform } from "./cli.js" import type { TerraformProvider } from "./provider.js" import { ConfigurationError, ParameterError } from "@garden-io/sdk/build/src/exceptions.js" -import { prepareVariables, setWorkspace, tfValidate } from "./helpers.js" +import { prepareVariables, ensureWorkspace, initTerraform } from "./helpers.js" import type { ConfigGraph, PluginCommand, PluginCommandParams } from "@garden-io/sdk/build/src/types.js" import { join } from "path" import fsExtra from "fs-extra" @@ -52,8 +52,8 @@ function makeRootCommand(commandName: string): PluginCommand { const root = join(ctx.projectRoot, provider.config.initRoot) const workspace = provider.config.workspace || null - await setWorkspace({ ctx, provider, root, log, workspace }) - await tfValidate({ ctx, provider, root, log }) + await ensureWorkspace({ ctx, provider, root, log, workspace }) + await initTerraform({ ctx, provider, root, log }) args = [commandName, ...(await prepareVariables(root, provider.config.variables)), ...args] @@ -95,8 +95,8 @@ function makeActionCommand(commandName: string): PluginCommand { const provider = ctx.provider as TerraformProvider const workspace = spec.workspace || null - await setWorkspace({ ctx, provider, root, log, workspace }) - await tfValidate({ ctx, provider, root, log }) + await ensureWorkspace({ ctx, provider, root, log, workspace }) + await initTerraform({ ctx, provider, root, log }) args = [commandName, ...(await prepareVariables(root, spec.variables)), ...args.slice(1)] await terraform(ctx, provider).spawnAndWait({ diff --git a/plugins/terraform/src/handlers.ts b/plugins/terraform/src/handlers.ts index f8c8d9cf0f..bbb3c76f34 100644 --- a/plugins/terraform/src/handlers.ts +++ b/plugins/terraform/src/handlers.ts @@ -9,7 +9,14 @@ import { join } from "path" import { deline } from "@garden-io/core/build/src/util/string.js" import { terraform } from "./cli.js" -import { applyStack, getStackStatus, getTfOutputs, prepareVariables, setWorkspace } from "./helpers.js" +import { + applyStack, + getStackStatus, + getTfOutputs, + prepareVariables, + ensureWorkspace, + initTerraform, +} from "./helpers.js" import type { TerraformProvider } from "./provider.js" import type { DeployActionHandler } from "@garden-io/core/build/src/plugin/action-types.js" import type { DeployState } from "@garden-io/core/build/src/types/service.js" @@ -25,6 +32,8 @@ export const getTerraformStatus: DeployActionHandler<"getStatus", TerraformDeplo const variables = spec.variables const workspace = spec.workspace || null + await ensureWorkspace({ log, ctx, provider, root, workspace }) + await initTerraform({ log, ctx, provider, root }) const status = await getStackStatus({ ctx, log, @@ -65,7 +74,7 @@ export const deployTerraform: DeployActionHandler<"deploy", TerraformDeploy> = a ` ) ) - await setWorkspace({ log, ctx, provider, root, workspace }) + await ensureWorkspace({ log, ctx, provider, root, workspace }) } return { @@ -99,7 +108,7 @@ export const deleteTerraformModule: DeployActionHandler<"delete", TerraformDeplo const variables = spec.variables const workspace = spec.workspace || null - await setWorkspace({ ctx, provider, root, log, workspace }) + await ensureWorkspace({ 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 }) diff --git a/plugins/terraform/src/helpers.ts b/plugins/terraform/src/helpers.ts index bab7a5f063..fc86168b68 100644 --- a/plugins/terraform/src/helpers.ts +++ b/plugins/terraform/src/helpers.ts @@ -47,13 +47,13 @@ interface TerraformParamsWithVariables extends TerraformParamsWithWorkspace { } /** - * 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. + * Initialize Terraform. */ -export async function tfValidate(params: TerraformParams) { +export async function initTerraform(params: TerraformParams) { const { log, ctx, provider, root } = params + // The Terraform init command is idempotent but can be slow so we first check if the stack is valid + // and return early if it is (if Terraform hasn't been initialized then validate returns false) const args = ["validate", "-json"] const res = await terraform(ctx, provider).json({ log, @@ -94,8 +94,8 @@ export async function tfValidate(params: TerraformParams) { ) errorMsg += dedent`\n\n${resultErrors.join("\n")} - Garden tried running "terraform init" but got the following error:\n - ${initError.message}` + Garden tried running "terraform init" but got the following error:\n + ${initError.message}` } else { // "terraform init" went through but there is still a validation error afterwards so we // add the retry error. @@ -143,9 +143,6 @@ type StackStatus = "up-to-date" | "outdated" | "error" export async function getStackStatus(params: TerraformParamsWithVariables): Promise { const { ctx, log, provider, root, variables } = params - await setWorkspace(params) - await tfValidate(params) - const statusLog = log.createLog({ name: "terraform" }).info("Running plan...") const plan = await terraform(ctx, provider).exec({ @@ -188,7 +185,7 @@ export async function getStackStatus(params: TerraformParamsWithVariables): Prom export async function applyStack(params: TerraformParamsWithVariables) { const { ctx, log, provider, root, variables } = params - await setWorkspace(params) + await ensureWorkspace(params) const args = ["apply", "-auto-approve", "-input=false", ...(await prepareVariables(root, variables))] const proc = await terraform(ctx, provider).spawn({ log, args, cwd: root }) @@ -279,7 +276,7 @@ export async function getWorkspaces(params: TerraformParams) { * 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) { +export async function ensureWorkspace(params: TerraformParamsWithWorkspace) { const { ctx, provider, root, log, workspace } = params if (!workspace) { diff --git a/plugins/terraform/src/init.ts b/plugins/terraform/src/init.ts index 410976bb5a..973376b2c8 100644 --- a/plugins/terraform/src/init.ts +++ b/plugins/terraform/src/init.ts @@ -7,12 +7,21 @@ */ import type { TerraformProvider } from "./provider.js" -import { applyStack, getRoot, getStackStatus, getTfOutputs, prepareVariables, setWorkspace } from "./helpers.js" +import { + applyStack, + getRoot, + getStackStatus, + getTfOutputs, + prepareVariables, + ensureWorkspace, + initTerraform, +} from "./helpers.js" import { deline } from "@garden-io/sdk/build/src/util/string.js" import type { ProviderHandlers } from "@garden-io/sdk/build/src/types.js" import { terraform } from "./cli.js" import { styles } from "@garden-io/core/build/src/logger/styles.js" +// TODO: 0.14, remove this function export const getEnvironmentStatus: ProviderHandlers["getEnvironmentStatus"] = async ({ ctx, log }) => { const provider = ctx.provider as TerraformProvider @@ -26,6 +35,21 @@ export const getEnvironmentStatus: ProviderHandlers["getEnvironmentStatus"] = as const variables = provider.config.variables const workspace = provider.config.workspace || null + // NOTE: This has a side effect although it shouldn't but this handler will be removed + // altogether in 0.14. + await ensureWorkspace({ log, ctx, provider, root, workspace }) + + const isValidRes = await terraform(ctx, provider).json({ + log, + args: ["validate", "-json"], + ignoreError: true, + cwd: root, + }) + + if (isValidRes.valid !== true) { + return { ready: false, outputs: {} } + } + const status = await getStackStatus({ log, ctx, provider, root, variables, workspace }) if (status === "up-to-date") { @@ -50,27 +74,43 @@ export const getEnvironmentStatus: ProviderHandlers["getEnvironmentStatus"] = as export const prepareEnvironment: ProviderHandlers["prepareEnvironment"] = async ({ ctx, log }) => { const provider = ctx.provider as TerraformProvider + const isPluginCommand = ctx.command?.name === "plugins" && ctx.command?.args.plugin === provider.name - if (!provider.config.initRoot) { - // Nothing to do! + // Return if there is no root stack, or if we're running one of the terraform plugin commands + if (!provider.config.initRoot || isPluginCommand) { return { status: { ready: true, outputs: {} } } } - const envStatus = await getEnvironmentStatus({ ctx, log }) - if (envStatus.ready) { - return { - status: envStatus, - } - } - 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, workspace }) + await ensureWorkspace({ log, ctx, provider, root, workspace }) + await initTerraform({ log, ctx, provider, root }) + + const status = await getStackStatus({ + log, + ctx, + provider, + root, + workspace, + variables: provider.config.variables, + }) + + if (status === "up-to-date") { + const tfOutputs = await getTfOutputs({ log, ctx, provider, root }) + return { status: { ready: true, outputs: tfOutputs } } + } else if (!provider.config.autoApply) { + const tfOutputs = await getTfOutputs({ log, ctx, provider, root }) + log.warn(deline` + Terraform stack is not up-to-date and ${styles.underline("autoApply")} is not enabled. Please run + ${styles.accent.bold("garden plugins terraform apply-root")} to make sure the stack is in the intended state. + `) + // Make sure the status is not cached when the stack is not up-to-date + return { status: { ready: true, outputs: tfOutputs, disableCache: true } } } + // Don't run apply when running plugin commands + await applyStack({ ctx, log, provider, root, variables: provider.config.variables, workspace }) const outputs = await getTfOutputs({ log, ctx, provider, root }) return { @@ -98,7 +138,7 @@ export const cleanupEnvironment: ProviderHandlers["cleanupEnvironment"] = async const variables = provider.config.variables const workspace = provider.config.workspace || null - await setWorkspace({ ctx, provider, root, log, workspace }) + await ensureWorkspace({ 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 }) diff --git a/plugins/terraform/test/common.ts b/plugins/terraform/test/common.ts index 1ccee9970f..338553bfa3 100644 --- a/plugins/terraform/test/common.ts +++ b/plugins/terraform/test/common.ts @@ -14,7 +14,7 @@ import type { TerraformProvider } from "../src/provider.js" import type { TestGarden } from "@garden-io/sdk/build/src/testing.js" import { makeTestGarden } from "@garden-io/sdk/build/src/testing.js" import type { Log, PluginContext } from "@garden-io/sdk/build/src/types.js" -import { getWorkspaces, setWorkspace } from "../src/helpers.js" +import { getWorkspaces, ensureWorkspace } from "../src/helpers.js" import { expect } from "chai" import { defaultTerraformVersion, terraform } from "../src/cli.js" import { fileURLToPath } from "node:url" @@ -94,7 +94,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { await terraform(ctx, provider).exec({ args: ["init"], cwd: root, log }) await terraform(ctx, provider).exec({ args: ["workspace", "new", "foo"], cwd: root, log }) - await setWorkspace({ ctx, provider, log, root, workspace: null }) + await ensureWorkspace({ ctx, provider, log, root, workspace: null }) const { workspaces, selected } = await getWorkspaces({ ctx, provider, log, root }) expect(selected).to.equal("foo") @@ -102,7 +102,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { }) it("does nothing if already on requested workspace", async () => { - await setWorkspace({ ctx, provider, log, root, workspace: "default" }) + await ensureWorkspace({ ctx, provider, log, root, workspace: "default" }) const { workspaces, selected } = await getWorkspaces({ ctx, provider, log, root }) expect(selected).to.equal("default") @@ -114,7 +114,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { await terraform(ctx, provider).exec({ args: ["workspace", "new", "foo"], cwd: root, log }) await terraform(ctx, provider).exec({ args: ["workspace", "select", "default"], cwd: root, log }) - await setWorkspace({ ctx, provider, log, root, workspace: "foo" }) + await ensureWorkspace({ ctx, provider, log, root, workspace: "foo" }) const { workspaces, selected } = await getWorkspaces({ ctx, provider, log, root }) expect(selected).to.equal("foo") @@ -122,7 +122,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { }) it("creates a new workspace if it doesn't already exist", async () => { - await setWorkspace({ ctx, provider, log, root, workspace: "foo" }) + await ensureWorkspace({ ctx, provider, log, root, workspace: "foo" }) const { workspaces, selected } = await getWorkspaces({ ctx, provider, log, root }) expect(selected).to.equal("foo") diff --git a/plugins/terraform/test/terraform.ts b/plugins/terraform/test/terraform.ts index 3af2ad8269..a4bd2d434b 100644 --- a/plugins/terraform/test/terraform.ts +++ b/plugins/terraform/test/terraform.ts @@ -21,7 +21,7 @@ import { LogLevel } from "@garden-io/sdk/build/src/types.js" import { gardenPlugin } from "../src/index.js" import type { TerraformProvider } from "../src/provider.js" import { DeployTask } from "@garden-io/core/build/src/tasks/deploy.js" -import { getWorkspaces, setWorkspace } from "../src/helpers.js" +import { getWorkspaces, ensureWorkspace } from "../src/helpers.js" import { resolveAction } from "@garden-io/core/build/src/graph/actions.js" import { RunTask } from "@garden-io/core/build/src/tasks/run.js" import { defaultTerraformVersion } from "../src/cli.js" @@ -433,7 +433,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: garden.log, name: "terraform" })) as TerraformProvider const ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) const _graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -478,7 +478,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: garden.log, name: "terraform" })) as TerraformProvider const _ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx: _ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx: _ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) const _graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -523,7 +523,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: _garden.log, name: "terraform" })) as TerraformProvider const ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -760,7 +760,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { log: _garden.log, }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) await actions.deploy.delete({ action: _action, log: _action.createLog(_garden.log), graph: _graph }) @@ -878,7 +878,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: garden.log, name: "terraform" })) as TerraformProvider const ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) const _graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -923,7 +923,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: garden.log, name: "terraform" })) as TerraformProvider const _ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx: _ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx: _ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) const _graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -968,7 +968,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { const provider = (await _garden.resolveProvider({ log: _garden.log, name: "terraform" })) as TerraformProvider const ctx = await _garden.getPluginContext({ provider, templateContext: undefined, events: undefined }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) graph = await _garden.getConfigGraph({ log: _garden.log, emit: false }) @@ -1204,7 +1204,7 @@ for (const terraformVersion of ["0.13.3", defaultTerraformVersion]) { log: _garden.log, }) - await setWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) + await ensureWorkspace({ ctx, provider, root: tfRoot, log: _garden.log, workspace: "default" }) await actions.deploy.delete({ action: _action, log: _action.createLog(_garden.log), graph: _graph })