diff --git a/docs/reference/providers/pulumi.md b/docs/reference/providers/pulumi.md index 0b9ab9dcf6..021282a952 100644 --- a/docs/reference/providers/pulumi.md +++ b/docs/reference/providers/pulumi.md @@ -32,7 +32,7 @@ providers: environments: # The version of pulumi to use. Set to `null` to use whichever version of `pulumi` is on your PATH. - version: 3.25.1 + version: 3.40.0 # Overrides the default plan directory path used when deploying with the `deployFromPreview` option for pulumi # modules. @@ -120,9 +120,9 @@ providers: The version of pulumi to use. Set to `null` to use whichever version of `pulumi` is on your PATH. -| Type | Allowed Values | Default | Required | -| -------- | ------------------------ | ---------- | -------- | -| `string` | "3.25.1", "3.24.1", null | `"3.25.1"` | Yes | +| Type | Allowed Values | Default | Required | +| -------- | ---------------------------------- | ---------- | -------- | +| `string` | "3.40.0", "3.39.4", "3.25.1", null | `"3.40.0"` | Yes | ### `providers[].previewDir` diff --git a/plugins/pulumi/cli.ts b/plugins/pulumi/cli.ts index 3d03516cbc..48fa23b8e2 100644 --- a/plugins/pulumi/cli.ts +++ b/plugins/pulumi/cli.ts @@ -59,17 +59,17 @@ export class GlobalPulumi extends CliWrapper { } export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { - "3.25.1": { - name: "pulumi-3-25-1", - description: "The pulumi CLI, v3.24.1", + "3.40.0": { + name: "pulumi-3-40-0", + description: "The pulumi CLI, v3.40.0", type: "binary", _includeInGardenImage: true, builds: [ { platform: "darwin", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-darwin-x64.tar.gz", - sha256: "c91ef64aedcd10a925858a21fc4b52f9a566b4f14bc0d175c0c51c7745cdd175", + url: "https://github.com/pulumi/pulumi/releases/download/v3.40.0/pulumi-v3.40.0-darwin-x64.tar.gz", + sha256: "3d48d917b64fb3a1380d47a5733726edb99c3a0f5565fe04cfa26ccb67cb415a", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -78,8 +78,8 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "darwin", architecture: "arm64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-darwin-arm64.tar.gz", - sha256: "a5ab29db86733b5f730a0f352b407aed64b82337a222a0c7cd1492b55189e6c1", + url: "https://github.com/pulumi/pulumi/releases/download/v3.40.0/pulumi-v3.40.0-darwin-arm64.tar.gz", + sha256: "f093cc460aa4a4773e6910db2b9a3ba71f6443b99194d8ad2752be66c4822861", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -88,8 +88,8 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "linux", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-linux-x64.tar.gz", - sha256: "71e94634492b54e09810649f3753a5b414f4a1895b012ee445c275f1a0f94c5c", + url: "https://github.com/pulumi/pulumi/releases/download/v3.40.0/pulumi-v3.40.0-linux-x64.tar.gz", + sha256: "7abc0ccb17e6b0b1ed89be0897bd6a73cb3c6784d7fb5c2e20ad2a8d976c42fe", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -98,8 +98,56 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "windows", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-windows-x64.zip", - sha256: "60d891f65e69e0eb14acb26e0a8a102d54ebc432060631f867c44b84cde09bdb", + url: "https://github.com/pulumi/pulumi/releases/download/v3.40.0/pulumi-v3.40.0-windows-x64.zip", + sha256: "f0ca025d7a47175852ed5a6e7f7c4e97f1d1326c448bd172e81e7130bd447b74", + extract: { + format: "zip", + targetPath: "pulumi/bin/pulumi.exe", + }, + }, + ], + }, + "3.39.4": { + name: "pulumi-3-39-4", + description: "The pulumi CLI, v3.39.4", + type: "binary", + _includeInGardenImage: false, + builds: [ + { + platform: "darwin", + architecture: "amd64", + url: "https://github.com/pulumi/pulumi/releases/download/v3.39.4/pulumi-v3.39.4-darwin-x64.tar.gz", + sha256: "a563f7d7f3dbda84fae61316ef335e204606ac4e79f8e43ffb6103972b9c26ff", + extract: { + format: "tar", + targetPath: "pulumi/pulumi", + }, + }, + { + platform: "darwin", + architecture: "arm64", + url: "https://github.com/pulumi/pulumi/releases/download/v3.39.4/pulumi-v3.39.4-darwin-arm64.tar.gz", + sha256: "20493f365df1d73417c8159b0259624f06afe7fa5bcb15305e47edbfb7c20eca", + extract: { + format: "tar", + targetPath: "pulumi/pulumi", + }, + }, + { + platform: "linux", + architecture: "amd64", + url: "https://github.com/pulumi/pulumi/releases/download/v3.39.4/pulumi-v3.39.4-linux-x64.tar.gz", + sha256: "dd3ad77debfb664bc9a79cc88789a091f1f4f420780a2feb622d31cda028ade9", + extract: { + format: "tar", + targetPath: "pulumi/pulumi", + }, + }, + { + platform: "windows", + architecture: "amd64", + url: "https://github.com/pulumi/pulumi/releases/download/v3.39.4/pulumi-v3.39.4-windows-x64.zip", + sha256: "fdea4e4caca4be39801f7e63bb36c9826a9965a36cac37cfa1244f5110d66864", extract: { format: "zip", targetPath: "pulumi/bin/pulumi.exe", @@ -107,17 +155,17 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { }, ], }, - "3.24.1": { - name: "pulumi-3-24-1", + "3.25.1": { + name: "pulumi-3-25-1", description: "The pulumi CLI, v3.24.1", type: "binary", - _includeInGardenImage: true, + _includeInGardenImage: false, builds: [ { platform: "darwin", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.24.1/pulumi-v3.24.1-darwin-x64.tar.gz", - sha256: "1bfafd10f189c4e57b9961ddf899055efb55649e7403fc1bdd33c89e5a9cce1c", + url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-darwin-x64.tar.gz", + sha256: "c91ef64aedcd10a925858a21fc4b52f9a566b4f14bc0d175c0c51c7745cdd175", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -126,8 +174,8 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "darwin", architecture: "arm64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.24.1/pulumi-v3.24.1-darwin-arm64.tar.gz", - sha256: "cdfd2d05beb66380b7eb2354ef45abbce17865441a465294cf8e5448a534eb7f", + url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-darwin-arm64.tar.gz", + sha256: "a5ab29db86733b5f730a0f352b407aed64b82337a222a0c7cd1492b55189e6c1", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -136,8 +184,8 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "linux", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.24.1/pulumi-v3.24.1-linux-x64.tar.gz", - sha256: "9341c23c1b0266a39ebc6dab2f36b20041226143481714cb0ba8bfbf3ef7ae7e", + url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-linux-x64.tar.gz", + sha256: "71e94634492b54e09810649f3753a5b414f4a1895b012ee445c275f1a0f94c5c", extract: { format: "tar", targetPath: "pulumi/pulumi", @@ -146,7 +194,7 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { { platform: "windows", architecture: "amd64", - url: "https://github.com/pulumi/pulumi/releases/download/v3.24.1/pulumi-v3.24.1-windows-x64.zip", + url: "https://github.com/pulumi/pulumi/releases/download/v3.25.1/pulumi-v3.25.1-windows-x64.zip", sha256: "7ccaace585dfd9b44659c876ac87c33ea892cd91c34cb7ad00081cec8032a329", extract: { format: "zip", @@ -160,4 +208,5 @@ export const pulumiCliSPecs: { [version: string]: PluginToolSpec } = { export const supportedVersions = Object.keys(pulumiCliSPecs) // Default to latest pulumi version -export const defaultPulumiVersion = "3.25.1" +export const defaultPulumiVersion = "3.40.0" +// export const defaultPulumiVersion = "3.25.1" diff --git a/plugins/pulumi/commands.ts b/plugins/pulumi/commands.ts index d76ffd00fa..ee9c9e2899 100644 --- a/plugins/pulumi/commands.ts +++ b/plugins/pulumi/commands.ts @@ -15,36 +15,65 @@ import { LogEntry, PluginCommand, PluginCommandParams, + PluginContext, + BuildTask, PluginTask, + GraphResults, } from "@garden-io/sdk/types" import { PulumiModule, PulumiProvider } from "./config" import { Profile } from "@garden-io/core/build/src/util/profiling" import { cancelUpdate, + getModifiedPlansDirPath, + getPlanFileName, getPreviewDirPath, + OperationCounts, + PreviewResult, previewStack, PulumiParams, refreshResources, reimportStack, selectStack, } from "./helpers" -import { dedent } from "@garden-io/sdk/util/string" -import { emptyDir } from "fs-extra" +import { dedent, deline } from "@garden-io/sdk/util/string" +import { BooleanParameter, parsePluginCommandArgs } from "@garden-io/sdk/util/cli" +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, pickBy } from "lodash" interface PulumiParamsWithService extends PulumiParams { service: GardenService } -type PulumiRunFn = (params: PulumiParamsWithService) => Promise +type PulumiRunFn = (params: PulumiParamsWithService) => Promise interface PulumiCommandSpec { name: string commandDescription: string - beforeFn?: ({ ctx: PluginContext, log: LogEntry }) => Promise + beforeFn?: ({ ctx, log }: { ctx: PluginContext; log: LogEntry }) => Promise runFn: PulumiRunFn + afterFn?: ({ ctx, log, results }: { ctx: PluginContext; log: LogEntry; results: GraphResults }) => Promise +} + +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[] = [ @@ -54,13 +83,61 @@ 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) + const { affectedResourcesCount, operationCounts, previewUrl, 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}`) + 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 = (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) - await previewStack({ ...params, logPreview: true, previewDirPath }) + 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 }, }, { @@ -89,13 +166,30 @@ const pulumiCommandSpecs: PulumiCommandSpec[] = [ }, ] +const makePluginContextForService = async ( + params: PulumiParamsWithService & { garden: Garden; graph: ConfigGraph } +) => { + const { log, garden, graph, service, provider, ctx } = params + const allProviders = await garden.resolveProviders(log) + const allModules = graph.getModules() + const templateContext = ModuleConfigContext.fromModule({ + garden, + resolvedProviders: allProviders, + module: service.module, + modules: allModules, + partialRuntimeResolution: false, + }) + const ctxForService = await garden.getPluginContext(provider, templateContext, ctx.events) + return ctxForService +} + interface PulumiPluginCommandTaskParams { garden: Garden graph: ConfigGraph log: LogEntry - service: GardenService commandName: string commandDescription: string + skipRuntimeDependencies: boolean runFn: PulumiRunFn pulumiParams: PulumiParamsWithService } @@ -104,26 +198,26 @@ interface PulumiPluginCommandTaskParams { class PulumiPluginCommandTask extends PluginTask { graph: ConfigGraph pulumiParams: PulumiParamsWithService - service: GardenService commandName: string commandDescription: string + skipRuntimeDependencies: boolean runFn: PulumiRunFn constructor({ garden, graph, log, - service, commandName, commandDescription, + skipRuntimeDependencies = false, runFn, pulumiParams, }: PulumiPluginCommandTaskParams) { - super({ garden, log, force: false, version: service.version }) + super({ garden, log, force: false, version: pulumiParams.service.version }) this.graph = graph - this.service = service this.commandName = commandName this.commandDescription = commandDescription + this.skipRuntimeDependencies = skipRuntimeDependencies this.runFn = runFn this.pulumiParams = pulumiParams const provider = pulumiParams.ctx.provider @@ -131,36 +225,55 @@ class PulumiPluginCommandTask extends PluginTask { } getName() { - return this.service.name + return this.pulumiParams.service.name } getDescription(): string { - return `running ${chalk.white(this.commandName)} for ${this.service.name}` + return `running ${chalk.white(this.commandName)} for ${this.pulumiParams.service.name}` } - async resolveDependencies(): Promise { + async resolveDependencies() { + // We process any build dependencies, since by default we don't have a build step for plugin tasks. + const buildDeps = flatten( + await Bluebird.map(Object.values(this.pulumiParams.service.module.buildDependencies), async (depModule) => { + return BuildTask.factory({ + garden: this.garden, + graph: this.graph, + log: this.log, + module: depModule, + force: false, + }) + }) + ) + + if (this.skipRuntimeDependencies) { + return [...buildDeps] + } + const pulumiServiceNames = this.graph .getModules() .filter((m) => m.type === "pulumi") .map((m) => m.name) // module names are the same as service names for pulumi modules - const deps = this.graph.getDependencies({ - nodeType: "deploy", - name: this.getName(), - recursive: false, - filter: (depNode) => pulumiServiceNames.includes(depNode.name), - }) - return deps.deploy.map((depService) => { - return new PulumiPluginCommandTask({ - garden: this.garden, - graph: this.graph, - log: this.log, - service: depService, - commandName: this.commandName, - commandDescription: this.commandDescription, - runFn: this.runFn, - pulumiParams: { ...this.pulumiParams, module: depService.module }, + const deployDeps = this.graph + .getDependencies({ + nodeType: "deploy", + name: this.getName(), + recursive: false, + filter: (depNode) => pulumiServiceNames.includes(depNode.name), }) - }) + .deploy.map((depService) => { + return new PulumiPluginCommandTask({ + garden: this.garden, + graph: this.graph, + log: this.log, + commandName: this.commandName, + commandDescription: this.commandDescription, + skipRuntimeDependencies: this.skipRuntimeDependencies, + runFn: this.runFn, + pulumiParams: { ...this.pulumiParams, module: depService.module, service: depService }, + }) + }) + return [...buildDeps, ...deployDeps] } async process(): Promise<{}> { @@ -171,31 +284,50 @@ class PulumiPluginCommandTask extends PluginTask { }) try { await selectStack(this.pulumiParams) - await this.runFn(this.pulumiParams) + // We need to make sure that the template resolution context is specific to this service's module. + const ctxForService = await makePluginContextForService({ + ...this.pulumiParams, + garden: this.garden, + graph: this.graph, + }) + const result = await this.runFn({ ...this.pulumiParams, ctx: ctxForService }) + 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) + const pulumiCommandOpts = { + "skip-dependencies": new BooleanParameter({ + help: deline`Run ${pulumiCommand} for the specified services, but not for any pulumi services that they depend on + (unless they're specified too).`, + alias: "nodeps", + }), + } + return { name, description: dedent` Runs ${pulumiCommand} for the specified pulumi services, in dependency order (or for all pulumi services if no service names are provided). + + If the --skip-dependencies option is used, ${pulumiCommand} will only be run for the specified services, but not + any pulumi services that they depend on (unless they're specified too). + + Note: The --skip-dependencies option has to be put after the -- when invoking pulumi plugin commands. `, // We don't want to call `garden.getConfigGraph` twice (we need to do it in the handler anyway) resolveModules: false, @@ -204,30 +336,25 @@ function makePulumiCommand({ name, commandDescription, beforeFn, runFn }: Pulumi chalk.bold.magenta(`Running ${chalk.white.bold(pulumiCommand)} for module ${chalk.white.bold(args[0] || "")}`), async handler({ garden, ctx, args, log }: PluginCommandParams) { - const serviceNames = args.length === 0 ? undefined : args const graph = await garden.getConfigGraph({ log, emit: false }) - if (beforeFn) { - await beforeFn({ ctx, log }) - } + const parsed = parsePluginCommandArgs({ + stringArgs: args, + optionSpec: pulumiCommandOpts, + cli: true, + }) + const { args: parsedArgs, opts } = parsed + const skipRuntimeDependencies = opts["skip-dependencies"] + const serviceNames = parsedArgs.length === 0 ? undefined : parsedArgs - const allProviders = await garden.resolveProviders(log) - const allModules = graph.getModules() + beforeFn && (await beforeFn({ ctx, log })) const provider = ctx.provider as PulumiProvider const services = graph.getServices({ names: serviceNames }).filter((s) => s.module.type === "pulumi") const tasks = await Bluebird.map(services, async (service) => { - const templateContext = ModuleConfigContext.fromModule({ - garden, - resolvedProviders: allProviders, - module: service.module, - modules: allModules, - partialRuntimeResolution: false, - }) - const ctxForModule = await garden.getPluginContext(provider, templateContext, ctx.events) const pulumiParams: PulumiParamsWithService = { - ctx: ctxForModule, + ctx, provider, log, module: service.module, @@ -239,17 +366,22 @@ function makePulumiCommand({ name, commandDescription, beforeFn, runFn }: Pulumi garden, graph, log, - service, commandName: name, commandDescription, + skipRuntimeDependencies, runFn, pulumiParams, }) }) - 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 } }, } } diff --git a/plugins/pulumi/helpers.ts b/plugins/pulumi/helpers.ts index 7fe28d5b9b..9a7ad69d08 100644 --- a/plugins/pulumi/helpers.ts +++ b/plugins/pulumi/helpers.ts @@ -7,8 +7,10 @@ */ import Bluebird from "bluebird" -import { isEmpty } 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" @@ -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 { @@ -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 } @@ -79,17 +80,28 @@ 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. + * * 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 { +): Promise { const { log, ctx, provider, module, logPreview, previewDirPath } = params const configPath = await applyConfig({ ...params, previewDirPath }) @@ -106,12 +118,26 @@ export async function previewStack( cwd: getModuleStackRoot(module), env: defaultPulumiEnv, }) + const plan = await readPulumiPlan(module, planPath) + const affectedResourcesCount = countAffectedResources(plan) + const operationCounts = countPlannedResourceOperations(plan) + let previewUrl: string | null = null if (logPreview) { - log.info(res.stdout) + 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 + return { planPath, affectedResourcesCount, operationCounts, previewUrl } } export async function getStackOutputs({ log, ctx, provider, module }: PulumiParams): Promise { @@ -268,25 +294,48 @@ 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 { -// 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, -// }) -// } +async function readPulumiPlan(module: PulumiModule, planPath: string): Promise { + 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 +} -// // 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))) +/** + * 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 +} -// return stepTypes.length === 1 && stepTypes[0] === "same" ? "up-to-date" : "outdated" -// } +/** + * 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[]) => { + const stepTypes = uniq(steps) + return stepTypes.length > 1 || stepTypes[0] !== "same" + }).length + + return affectedResourcesCount +} // Helpers for plugin commands @@ -401,6 +450,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` } diff --git a/scripts/compute-ext-tool-binary-shas.sh b/scripts/compute-ext-tool-binary-shas.sh index 378f1c4ce8..b6229d1cee 100755 --- a/scripts/compute-ext-tool-binary-shas.sh +++ b/scripts/compute-ext-tool-binary-shas.sh @@ -2,14 +2,10 @@ # Downloads binaries, computes and prints their SHAs. Useful when adding new tools or new versions of existing tools. # -# Usage: ./compute-ext-tool-binary-shas +# Usage: ./compute-ext-tool-binary-shas -darwin_dl_url=$1 -linux_dl_url=$2 -win_dl_url=$3 - -platforms=("Darwin" "Linux" "Windows") -urls=($1 $2 $3) +platforms=("Darwin-x64" "Darwin-arm64" "Linux" "Windows") +urls=($1 $2 $3 $4) for i in ${!platforms[@]}; do echo "Downloading ${platforms[$i]} binary at ${urls[$i]}..." diff --git a/sdk/package.json b/sdk/package.json index 767d068690..82144f989c 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -20,7 +20,8 @@ ], "main": "index.js", "dependencies": { - "@garden-io/core": "*" + "@garden-io/core": "*", + "minimist": "^1.2.7" }, "devDependencies": { "@types/node": "^14.14.31", diff --git a/sdk/types.ts b/sdk/types.ts index 9a9ec367de..d20f6f53e2 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -12,6 +12,7 @@ export { GardenModule } from "@garden-io/core/build/src/types/module" export { GardenService } from "@garden-io/core/build/src/types/service" export { GraphResults } from "@garden-io/core/build/src/task-graph" export { PluginTask } from "@garden-io/core/build/src/tasks/plugin" +export { BuildTask } from "@garden-io/core/build/src/tasks/build" export { LogLevel } from "@garden-io/core/build/src/logger/logger" export { LogEntry } from "@garden-io/core/build/src/logger/log-entry" export { PluginContext } from "@garden-io/core/build/src/plugin-context" diff --git a/sdk/util/cli.ts b/sdk/util/cli.ts new file mode 100644 index 0000000000..7b3a6b9c0a --- /dev/null +++ b/sdk/util/cli.ts @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018-2022 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Parameters } from "@garden-io/core/build/src/cli/params" +import { prepareMinimistOpts } from "@garden-io/core/build/src/cli/helpers" +import minimist from "minimist" + +export { + BooleanParameter, + ChoicesParameter, + DurationParameter, + IntegerParameter, + PathParameter, + PathsParameter, + StringOption, + StringsParameter, + TagsOption, +} from "@garden-io/core/build/src/cli/params" + +/** + * Parses the given CLI arguments using minimist, according to the CLI options spec provided. Useful for plugin commands + * that want to support CLI options. + * + * @param stringArgs Raw string arguments + * @param optionSpec A map of CLI options that should be detected and parsed. + * @param cli If true, prefer `option.cliDefault` to `option.defaultValue`. + * @param skipDefault Defaults to `false`. If `true`, don't populate default values. + */ +export function parsePluginCommandArgs(params: { + stringArgs: string[] + optionSpec: Parameters + cli: boolean + skipDefault?: boolean +}) { + const { stringArgs, optionSpec } = params + const minimistOpts = prepareMinimistOpts({ + options: optionSpec, + ...params, + }) + + const parsed = minimist(stringArgs, { + ...minimistOpts, + "--": true, + }) + + return { + args: parsed["_"], + opts: parsed, + } +} diff --git a/yarn.lock b/yarn.lock index 96ec57b801..e37c94c22d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12492,6 +12492,11 @@ minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.3, minimist@^1. resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" + integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"