Skip to content

Commit

Permalink
improvement(pulumi): improve preview output
Browse files Browse the repository at this point in the history
We now only show the output of `pulumi preview` when the generated plan
is not a no-op. This makes the output much easier to parse visually
during a manual review process.

We now also copy plans that aren't no-ops to a subdirectory of the preview
directory for easier review and deployment.
  • Loading branch information
thsig committed Oct 21, 2022
1 parent 2878f6f commit 5d96b7c
Show file tree
Hide file tree
Showing 2 changed files with 62 additions and 28 deletions.
24 changes: 20 additions & 4 deletions plugins/pulumi/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { PulumiModule, PulumiProvider } from "./config"
import { Profile } from "@garden-io/core/build/src/util/profiling"
import {
cancelUpdate,
getModifiedPlansDirPath,
getPlanFileName,
getPreviewDirPath,
previewStack,
PulumiParams,
Expand All @@ -30,9 +32,10 @@ import {
selectStack,
} from "./helpers"
import { dedent } from "@garden-io/sdk/util/string"
import { emptyDir } from "fs-extra"
import { copy, emptyDir } from "fs-extra"
import { ModuleConfigContext } from "@garden-io/core/build/src/config/template-contexts/module"
import { deletePulumiService } from "./handlers"
import { join } from "path"

interface PulumiParamsWithService extends PulumiParams {
service: GardenService
Expand All @@ -54,13 +57,26 @@ const pulumiCommandSpecs: PulumiCommandSpec[] = [
beforeFn: async ({ ctx, log }) => {
const previewDirPath = getPreviewDirPath(ctx)
// We clear the preview dir, so that it contains only the plans generated by this preview command.
log.info(`Clearing preview dir at ${previewDirPath}...`)
log.debug(`Clearing preview dir at ${previewDirPath}...`)
await emptyDir(previewDirPath)
},
runFn: async (params) => {
const { ctx } = params
const { ctx, module, log } = params
const previewDirPath = getPreviewDirPath(ctx)
await previewStack({ ...params, logPreview: true, previewDirPath })
const { affectedResourcesCount, planPath } = await previewStack({
...params,
logPreview: true,
previewDirPath,
})
if (affectedResourcesCount > 0) {
// We copy the plan to a subdirectory of the preview dir.
// This is to facilitate copying only those plans that aren't no-ops out of the preview dir for subsequent
// use in a deployment.
const planFileName = getPlanFileName(module, ctx.environmentName)
const modifiedPlanPath = join(getModifiedPlansDirPath(ctx), planFileName)
await copy(planPath, modifiedPlanPath)
log.debug(`Copied plan to ${modifiedPlanPath}`)
}
},
},
{
Expand Down
66 changes: 42 additions & 24 deletions plugins/pulumi/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import Bluebird from "bluebird"
import { isEmpty } from "lodash"
import { isEmpty, uniq } from "lodash"
import { safeLoad } from "js-yaml"
import { merge } from "json-merge-patch"
import { extname, join, resolve } from "path"
Expand Down Expand Up @@ -80,16 +80,19 @@ type StackStatus = "up-to-date" | "outdated" | "error"
export const stackVersionKey = "garden.io-service-version"

/**
* Used by the `garden plugins pulumi preview` command.
*
* Merges any values in the module's `pulumiVars` and `pulumiVariables`, then uses `pulumi preview` to generate
* a plan (using the merged config).
*
* If `logPreview = true`, logs the output of `pulumi preview`.
*
* Returns the path to the generated plan.
* Returns the path to the generated plan, and the number of resources affected by the plan (zero resources means the
* plan is a no-op).
*/
export async function previewStack(
params: PulumiParams & { logPreview: boolean; previewDirPath?: string }
): Promise<string> {
): Promise<{ planPath: string; affectedResourcesCount: number }> {
const { log, ctx, provider, module, logPreview, previewDirPath } = params

const configPath = await applyConfig({ ...params, previewDirPath })
Expand All @@ -106,12 +109,17 @@ export async function previewStack(
cwd: getModuleStackRoot(module),
env: defaultPulumiEnv,
})
const affectedResourcesCount = await countAffectedResources(module, planPath)
if (logPreview) {
log.info(res.stdout)
if (affectedResourcesCount > 0) {
log.info(res.stdout)
} else {
log.info(`No resources were changed in the generated plan for ${chalk.cyan(module.name)}.`)
}
} else {
log.verbose(res.stdout)
}
return planPath
return { planPath, affectedResourcesCount }
}

export async function getStackOutputs({ log, ctx, provider, module }: PulumiParams): Promise<any> {
Expand Down Expand Up @@ -268,25 +276,31 @@ export async function getStackStatusFromTag(params: PulumiParams & { serviceVers
return tagVersion === params.serviceVersion && resources && resources.length > 0 ? "up-to-date" : "outdated"
}

// Keeping this here for now, in case we want to reuse this logic
// export async function getStackStatusFromPlanPath(module: PulumiModule, planPath: string): Promise<StackStatus> {
// let plan: PulumiPlan
// try {
// plan = JSON.parse((await readFile(planPath)).toString()) as PulumiPlan
// } catch (err) {
// const errMsg = `An error occurred while reading a pulumi plan file at ${planPath}: ${err.message}`
// throw new FilesystemError(errMsg, {
// planPath,
// moduleName: module.name,
// })
// }

// // If all steps across all resource plans are of the "same" type, then the plan indicates that the
// // stack doesn't need to be updated (so we don't need to redeploy).
// const stepTypes = uniq(flatten(Object.values(plan.resourcePlans).map((p) => p.steps)))

// return stepTypes.length === 1 && stepTypes[0] === "same" ? "up-to-date" : "outdated"
// }
/**
* Reads the plan at `planPath` and counts the number of resources in it that have one or more steps that aren't of
* the `"same"` type (i.e. that aren't no-ops).
*/
export async function countAffectedResources(module: PulumiModule, planPath: string): Promise<number> {
let plan: PulumiPlan
try {
plan = JSON.parse((await readFile(planPath)).toString()) as PulumiPlan
} catch (err) {
const errMsg = `An error occurred while reading a pulumi plan file at ${planPath}: ${err.message}`
throw new FilesystemError(errMsg, {
planPath,
moduleName: module.name,
})
}

const affectedResourcesCount = Object.values(plan.resourcePlans)
.map((p) => p.steps)
.filter((steps: string[]) => {
const stepTypes = uniq(steps)
return stepTypes.length > 1 || stepTypes[0] !== "same"
}).length

return affectedResourcesCount
}

// Helpers for plugin commands

Expand Down Expand Up @@ -401,6 +415,10 @@ function getDefaultPreviewDirPath(ctx: PluginContext): string {
return join(getPluginOutputsPath(ctx, "pulumi"), "last-preview")
}

export function getModifiedPlansDirPath(ctx: PluginContext): string {
return join(getPreviewDirPath(ctx), "modified")
}

export function getPlanFileName(module: PulumiModule, environmentName: string): string {
return `${module.name}.${environmentName}.plan.json`
}
Expand Down

0 comments on commit 5d96b7c

Please sign in to comment.