Skip to content

Commit

Permalink
improvement(pulumi): better preview summaries
Browse files Browse the repository at this point in the history
We now generate a total summary of a plan run when running `garden
plugins pulumi preview`, which includes preview links, step/operation
counts (and more) for each pulumi service affected by the plan.

The total summary also includes top-level step counts by operation type
(across all affected services).

This plan is written to a `plan-summary.json` file inside the
`.garden/pulumi.outputs/last-preview` directory.

This file is designed to be useful for further automation, e.g. in a
CI/CD context.
  • Loading branch information
thsig committed Oct 21, 2022
1 parent 3137e0c commit 977877e
Show file tree
Hide file tree
Showing 2 changed files with 122 additions and 27 deletions.
92 changes: 76 additions & 16 deletions plugins/pulumi/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PluginContext,
BuildTask,
PluginTask,
GraphResults,
} from "@garden-io/sdk/types"

import { PulumiModule, PulumiProvider } from "./config"
Expand All @@ -27,30 +28,51 @@ import {
getModifiedPlansDirPath,
getPlanFileName,
getPreviewDirPath,
OperationCounts,
PreviewResult,
previewStack,
PulumiParams,
refreshResources,
reimportStack,
selectStack,
} from "./helpers"
import { dedent } from "@garden-io/sdk/util/string"
import { copy, emptyDir } from "fs-extra"
import { copy, writeJSON, emptyDir } from "fs-extra"
import { ModuleConfigContext } from "@garden-io/core/build/src/config/template-contexts/module"
import { splitLast } from "@garden-io/core/build/src/util/util"
import { deletePulumiService } from "./handlers"
import { join } from "path"
import { flatten } from "lodash"
import { flatten, pickBy } from "lodash"

interface PulumiParamsWithService extends PulumiParams {
service: GardenService
}

type PulumiRunFn = (params: PulumiParamsWithService) => Promise<void>
type PulumiRunFn = (params: PulumiParamsWithService) => Promise<any>

interface PulumiCommandSpec {
name: string
commandDescription: string
beforeFn?: ({ ctx, log }: { ctx: PluginContext; log: LogEntry }) => Promise<void>
beforeFn?: ({ ctx, log }: { ctx: PluginContext; log: LogEntry }) => Promise<any>
runFn: PulumiRunFn
afterFn?: ({ ctx, log, results }: { ctx: PluginContext; log: LogEntry; results: GraphResults }) => Promise<any>
}

interface TotalSummary {
/**
* The ISO timestamp of when the plan was completed.
*/
completedAt: string
/**
* The total number of operations by step type (excluding `same` steps).
*/
totalStepCounts: OperationCounts
/**
* A more detailed summary for each pulumi service affected by the plan.
*/
results: {
[serviceName: string]: PreviewResult
}
}

const pulumiCommandSpecs: PulumiCommandSpec[] = [
Expand All @@ -66,7 +88,7 @@ const pulumiCommandSpecs: PulumiCommandSpec[] = [
runFn: async (params) => {
const { ctx, module, log } = params
const previewDirPath = getPreviewDirPath(ctx)
const { affectedResourcesCount, planPath } = await previewStack({
const { affectedResourcesCount, operationCounts, previewUrl, planPath } = await previewStack({
...params,
logPreview: true,
previewDirPath,
Expand All @@ -79,7 +101,42 @@ const pulumiCommandSpecs: PulumiCommandSpec[] = [
const modifiedPlanPath = join(getModifiedPlansDirPath(ctx), planFileName)
await copy(planPath, modifiedPlanPath)
log.debug(`Copied plan to ${modifiedPlanPath}`)
return {
affectedResourcesCount,
operationCounts,
modifiedPlanPath,
previewUrl,
}
} else {
return null
}
},
afterFn: async ({ ctx, log, results }) => {
// No-op plans (i.e. where no resources were changed) are omitted here.
const pulumiTaskResults = Object.fromEntries(
Object.entries(pickBy(results, (r) => r && r.type === "plugin" && r.output)).map(([k, r]) => [
splitLast(k, ".")[1],
r ? r.output : null,
])
)
const totalStepCounts: OperationCounts = {}
for (const result of Object.values(pulumiTaskResults)) {
const opCounts = (<PreviewResult>result).operationCounts
for (const [stepType, count] of Object.entries(opCounts)) {
totalStepCounts[stepType] = (totalStepCounts[stepType] || 0) + count
}
}
const totalSummary: TotalSummary = {
completedAt: new Date().toISOString(),
totalStepCounts,
results: pulumiTaskResults,
}
const previewDirPath = getPreviewDirPath(ctx)
const summaryPath = join(previewDirPath, "plan-summary.json")
await writeJSON(summaryPath, totalSummary, { spaces: 2 })
log.info("")
log.info(chalk.green(`Wrote plan summary to ${chalk.white(summaryPath)}`))
return totalSummary
},
},
{
Expand Down Expand Up @@ -204,23 +261,23 @@ class PulumiPluginCommandTask extends PluginTask {
})
try {
await selectStack(this.pulumiParams)
await this.runFn(this.pulumiParams)
const result = await this.runFn(this.pulumiParams)
log.setSuccess({
msg: chalk.green(`Success (took ${log.getDuration(1)} sec)`),
})
return result
} catch (err) {
log.setError({
msg: chalk.red(`Failed! (took ${log.getDuration(1)} sec)`),
})
throw err
}
log.setSuccess({
msg: chalk.green(`Success (took ${log.getDuration(1)} sec)`),
})
return {}
}
}

export const getPulumiCommands = (): PluginCommand[] => pulumiCommandSpecs.map(makePulumiCommand)

function makePulumiCommand({ name, commandDescription, beforeFn, runFn }: PulumiCommandSpec) {
function makePulumiCommand({ name, commandDescription, beforeFn, runFn, afterFn }: PulumiCommandSpec) {
const description = commandDescription || `pulumi ${name}`
const pulumiCommand = chalk.bold(description)

Expand All @@ -240,9 +297,7 @@ function makePulumiCommand({ name, commandDescription, beforeFn, runFn }: Pulumi
const serviceNames = args.length === 0 ? undefined : args
const graph = await garden.getConfigGraph({ log, emit: false })

if (beforeFn) {
await beforeFn({ ctx, log })
}
beforeFn && (await beforeFn({ ctx, log }))

const allProviders = await garden.resolveProviders(log)
const allModules = graph.getModules()
Expand Down Expand Up @@ -280,9 +335,14 @@ function makePulumiCommand({ name, commandDescription, beforeFn, runFn }: Pulumi
})
})

await garden.processTasks(tasks)
const results = await garden.processTasks(tasks)

let commandResult: any = {}
if (afterFn) {
commandResult = await afterFn({ ctx, log, results })
}

return { result: {} }
return { result: commandResult }
},
}
}
57 changes: 46 additions & 11 deletions plugins/pulumi/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
*/

import Bluebird from "bluebird"
import { isEmpty, uniq } from "lodash"
import { countBy, flatten, isEmpty, uniq } from "lodash"
import { safeLoad } from "js-yaml"
import stripAnsi from "strip-ansi"
import chalk from "chalk"
import { merge } from "json-merge-patch"
import { extname, join, resolve } from "path"
import { ensureDir, pathExists, readFile } from "fs-extra"
Expand All @@ -20,7 +22,6 @@ import { getPluginOutputsPath } from "@garden-io/sdk"
import { LogEntry, PluginContext } from "@garden-io/sdk/types"
import { defaultPulumiEnv, pulumi } from "./cli"
import { PulumiModule, PulumiProvider } from "./config"
import chalk from "chalk"
import { deline } from "@garden-io/sdk/util/string"

export interface PulumiParams {
Expand Down Expand Up @@ -54,7 +55,7 @@ export interface PulumiPlan {
// The goal state for the resource
goal: DeepPrimitiveMap
// The steps to be performed on the resource.
steps: string[] // When the plan is
steps: string[]
// The proposed outputs for the resource, if any. Purely advisory.
outputs: DeepPrimitiveMap
}
Expand All @@ -79,6 +80,14 @@ type StackStatus = "up-to-date" | "outdated" | "error"

export const stackVersionKey = "garden.io-service-version"

export interface PreviewResult {
planPath: string
affectedResourcesCount: number
operationCounts: OperationCounts
// Only null if we didn't find a preview URL in the output (should never happen, but just in case).
previewUrl: string | null
}

/**
* Used by the `garden plugins pulumi preview` command.
*
Expand All @@ -92,7 +101,7 @@ export const stackVersionKey = "garden.io-service-version"
*/
export async function previewStack(
params: PulumiParams & { logPreview: boolean; previewDirPath?: string }
): Promise<{ planPath: string; affectedResourcesCount: number }> {
): Promise<PreviewResult> {
const { log, ctx, provider, module, logPreview, previewDirPath } = params

const configPath = await applyConfig({ ...params, previewDirPath })
Expand All @@ -109,17 +118,26 @@ export async function previewStack(
cwd: getModuleStackRoot(module),
env: defaultPulumiEnv,
})
const affectedResourcesCount = await countAffectedResources(module, planPath)
const plan = await readPulumiPlan(module, planPath)
const affectedResourcesCount = countAffectedResources(plan)
const operationCounts = countPlannedResourceOperations(plan)
let previewUrl: string | null = null
if (logPreview) {
if (affectedResourcesCount > 0) {
const cleanedOutput = stripAnsi(res.stdout)
// We try to find the preview URL using a regex (which should keep working as long as the output format
// doesn't change). If we can't find a preview URL, we simply default to `null`. As far as I can tell,
// Pulumi's automation API doesn't provide this URL in any sort of structured output. -THS
const urlMatch = cleanedOutput.match(/View Live: ([^\s]*)/)
previewUrl = urlMatch ? urlMatch[1] : null
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, affectedResourcesCount }
return { planPath, affectedResourcesCount, operationCounts, previewUrl }
}

export async function getStackOutputs({ log, ctx, provider, module }: PulumiParams): Promise<any> {
Expand Down Expand Up @@ -276,22 +294,39 @@ export async function getStackStatusFromTag(params: PulumiParams & { serviceVers
return tagVersion === params.serviceVersion && resources && resources.length > 0 ? "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> {
async function readPulumiPlan(module: PulumiModule, planPath: string): Promise<PulumiPlan> {
let plan: PulumiPlan
try {
plan = JSON.parse((await readFile(planPath)).toString()) as PulumiPlan
return plan
} 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,
})
}
}

export interface OperationCounts {
[operationType: string]: number
}

/**
* Counts the number of steps in plan by operation type.
*/
export function countPlannedResourceOperations(plan: PulumiPlan): OperationCounts {
const allSteps = flatten(Object.values(plan.resourcePlans).map((p) => p.steps))
const counts: OperationCounts = countBy(allSteps)
delete counts.same
return counts
}

/**
* Counts the number of resources in `plan` that have one or more steps that aren't of the `same` type
* (i.e. that aren't no-ops).
*/
export function countAffectedResources(plan: PulumiPlan): number {
const affectedResourcesCount = Object.values(plan.resourcePlans)
.map((p) => p.steps)
.filter((steps: string[]) => {
Expand Down

0 comments on commit 977877e

Please sign in to comment.