diff --git a/CHANGELOG.md b/CHANGELOG.md index 38efb3ec..7bf325ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ - feat: Add deletion of stack after destroy (remove flag) +- feat: Add support for an `install` command, similar to + [setup-pulumi](https://github.com/marketplace/actions/setup-pulumi) + -- ## 3.20.0 (2022-11-10) diff --git a/README.md b/README.md index f9de0a2a..53bf2b74 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,12 @@ This will check out the existing directory and run `pulumi preview`. The action can be configured with the following arguments: - `command` (required) - The command to run as part of the action. Accepted - values are `up` (update), `refresh`, `destroy` and `preview`. + values are `up` (alias: update), `refresh`, `destroy`, `install`, and + `preview`. - `stack-name` (required) - The name of the stack that Pulumi will be operating - on. Use the fully quaified org-name/stack-name when operating on a stack outside - of your individual account. + on. Use the fully quaified org-name/stack-name when operating on a stack + outside of your individual account. - `work-dir` (optional) - The location of your Pulumi files. Defaults to `./`. @@ -105,8 +106,8 @@ The action can be configured with the following arguments: `Pulumi..yaml` file that you will need to add back to source control as part of the action if you wish to perform any further tasks with that stack. -- `remove` - (optional) Removes the target stack if all resources are - destroyed. Used only with `destroy` command. +- `remove` - (optional) Removes the target stack if all resources are destroyed. + Used only with `destroy` command. - `pulumi-version` - (optional) Install a specific version of the Pulumi CLI. Defaults to "^3" @@ -115,6 +116,13 @@ By default, this action will try to authenticate Pulumi with the `PULUMI_ACCESS_TOKEN` then you will need to specify an alternative backend via the `cloud-url` argument. +### Installation Only + +Unlike the other possible commands, the `install` command does not directly +correspond to a CLI subcommand of the `pulumi` binary. Instead, workflow steps +that provide `command: install` will install the Pulumi CLI and exit without +performing any other operations. + ### Stack Outputs [Stack outputs](https://www.pulumi.com/docs/intro/concepts/stack/#outputs) are diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts index 90f0b4da..437a9509 100644 --- a/src/__tests__/run.test.ts +++ b/src/__tests__/run.test.ts @@ -1,7 +1,53 @@ import * as pulumiCli from "../libs/pulumi-cli"; import { login } from '../login'; +// import * as loginModule from '../login'; const spy = jest.spyOn(pulumiCli, 'run'); +const loginSpy = jest.spyOn(require('../login'), 'login'); + +const installConfig: Record = { + command: 'install', + 'stack-name': 'dev', + 'work-dir': './', + 'cloud-url': 'file://~', + 'github-token': 'n/a', + 'pulumi-version': '^3', + 'comment-on-pr': 'false', +}; + +describe('main.downloadOnly', () => { + let oldWorkspace = ''; + beforeEach(() => { + spy.mockClear(); + jest.resetModules(); + // Save, then restore the current env var for GITHUB_WORKSPACE + oldWorkspace = process.env.GITHUB_WORKSPACE; + process.env.GITHUB_WORKSPACE = 'n/a'; + }); + afterEach(() => { + process.env.GITHUB_WORKSPACE = oldWorkspace; + }); + it('should ensure nothing beyond downloadCli is executed', async () => { + jest.mock('@actions/core', () => ({ + getInput: jest.fn((name: string) => { + return installConfig[name]; + }), + info: jest.fn(), + })); + jest.mock('@actions/github', () => ({ + context: {}, + })); + jest.mock('../libs/pulumi-cli', () => ({ + downloadCli: jest.fn(), + })); + const { makeConfig } = require('../config'); + const { runAction } = jest.requireActual('../run'); + const conf = await makeConfig(); + expect(conf).toBeTruthy(); + await runAction(conf); + expect(loginSpy).not.toHaveBeenCalled(); + }); +}); describe('main.login', () => { beforeEach(() => { diff --git a/src/config.ts b/src/config.ts index a46ed684..689466f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,11 +4,12 @@ import * as rt from 'runtypes'; import { parseArray, parseBoolean, parseNumber } from './libs/utils'; export const command = rt.Union( - rt.Literal('up'), - rt.Literal('update'), - rt.Literal('refresh'), rt.Literal('destroy'), + rt.Literal('install'), rt.Literal('preview'), + rt.Literal('refresh'), + rt.Literal('up'), + rt.Literal('update'), ); export type Commands = rt.Static; diff --git a/src/main.ts b/src/main.ts index 052fe08b..73fab472 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,109 +1,11 @@ -import { resolve } from 'path'; import * as core from '@actions/core'; -import { - ConfigMap, - LocalProgramArgs, - LocalWorkspace, - LocalWorkspaceOptions, -} from '@pulumi/pulumi/automation'; -import invariant from 'ts-invariant'; -import YAML from 'yaml'; -import { Commands, makeConfig } from './config'; -import { environmentVariables } from './libs/envs'; -import { handlePullRequestMessage } from './libs/pr'; -import * as pulumiCli from './libs/pulumi-cli'; -import { login } from './login'; +import { makeConfig } from './config'; +import { runAction } from './run'; const main = async () => { const config = await makeConfig(); core.debug('Configuration is loaded'); - - await pulumiCli.downloadCli(config.options.pulumiVersion); - await login(config.cloudUrl, environmentVariables.PULUMI_ACCESS_TOKEN); - - const workDir = resolve( - environmentVariables.GITHUB_WORKSPACE, - config.workDir, - ); - core.debug(`Working directory resolved at ${workDir}`); - - const stackArgs: LocalProgramArgs = { - stackName: config.stackName, - workDir: workDir, - }; - - const stackOpts: LocalWorkspaceOptions = {}; - if (config.secretsProvider != '') { - stackOpts.secretsProvider = config.secretsProvider; - } - - const stack = await (config.upsert - ? LocalWorkspace.createOrSelectStack(stackArgs, stackOpts) - : LocalWorkspace.selectStack(stackArgs, stackOpts)); - - const projectSettings = await stack.workspace.projectSettings(); - const projectName = projectSettings.name; - - const onOutput = (msg: string) => { - core.debug(msg); - core.info(msg); - }; - - if (config.configMap != '') { - const configMap: ConfigMap = YAML.parse(config.configMap); - await stack.setAllConfig(configMap); - } - - if (config.refresh) { - core.startGroup(`Refresh stack on ${config.stackName}`); - await stack.refresh({ onOutput }); - core.endGroup(); - } - - core.startGroup(`pulumi ${config.command} on ${config.stackName}`); - - const actions: Record Promise> = { - up: () => stack.up({ onOutput, ...config.options }).then((r) => r.stdout), - update: () => - stack.up({ onOutput, ...config.options }).then((r) => r.stdout), - refresh: () => - stack.refresh({ onOutput, ...config.options }).then((r) => r.stdout), - destroy: () => - stack.destroy({ onOutput, ...config.options }).then((r) => r.stdout), - preview: async () => { - const { stdout, stderr } = await stack.preview(config.options); - onOutput(stdout); - onOutput(stderr); - return stdout; - }, - }; - - core.debug(`Running action ${config.command}`); - const output = await actions[config.command](); - core.debug(`Done running action ${config.command}`); - - core.setOutput('output', output); - - const outputs = await stack.outputs(); - - for (const [outKey, outExport] of Object.entries(outputs)) { - core.setOutput(outKey, outExport.value); - if (outExport.secret) { - core.setSecret(outExport.value); - } - } - - if (config.commentOnPr && config.isPullRequest) { - core.debug(`Commenting on pull request`); - invariant(config.githubToken, 'github-token is missing.'); - handlePullRequestMessage(config, projectName, output); - } - - if (config.remove && config.command === 'destroy') { - stack.workspace.removeStack(stack.name) - } - - core.endGroup(); + runAction(config); }; (async () => { @@ -116,4 +18,4 @@ const main = async () => { core.setFailed(err.message); } } -})(); +})(); \ No newline at end of file diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 00000000..62365b4c --- /dev/null +++ b/src/run.ts @@ -0,0 +1,137 @@ +import { resolve } from 'path'; +import * as core from '@actions/core'; +import { cacheFile } from '@actions/tool-cache'; +import { + ConfigMap, + LocalProgramArgs, + LocalWorkspace, + LocalWorkspaceOptions, + PluginInfo, + Stack, +} from '@pulumi/pulumi/automation'; +import invariant from 'ts-invariant'; +import YAML from 'yaml'; +import { Commands, Config } from './config'; +import { environmentVariables } from './libs/envs'; +import { handlePullRequestMessage } from './libs/pr'; +import * as pulumiCli from './libs/pulumi-cli'; +import { login } from './login'; + +function downloadOnly(cmd: Commands): boolean { + return cmd === 'install'; +} + +const disableCache = false; + +export const runAction = async (config: Config): Promise => { + + await pulumiCli.downloadCli(config.options.pulumiVersion); + + if(downloadOnly(config.command)) { + core.info("Pulumi has been successfully installed."); + return; + } + core.info('Pulumi is going forward anyway!'); + + await login(config.cloudUrl, environmentVariables.PULUMI_ACCESS_TOKEN); + + const workDir = resolve( + environmentVariables.GITHUB_WORKSPACE, + config.workDir, + ); + core.debug(`Working directory resolved at ${workDir}`); + + const stackArgs: LocalProgramArgs = { + stackName: config.stackName, + workDir: workDir, + }; + + const stackOpts: LocalWorkspaceOptions = {}; + if (config.secretsProvider != '') { + stackOpts.secretsProvider = config.secretsProvider; + } + + const stack = await (config.upsert + ? LocalWorkspace.createOrSelectStack(stackArgs, stackOpts) + : LocalWorkspace.selectStack(stackArgs, stackOpts)); + + const projectSettings = await stack.workspace.projectSettings(); + const projectName = projectSettings.name; + + const onOutput = (msg: string) => { + core.debug(msg); + core.info(msg); + }; + + if (config.configMap != '') { + const configMap: ConfigMap = YAML.parse(config.configMap); + await stack.setAllConfig(configMap); + } + + if (config.refresh) { + core.startGroup(`Refresh stack on ${config.stackName}`); + await stack.refresh({ onOutput }); + core.endGroup(); + } + + core.startGroup(`pulumi ${config.command} on ${config.stackName}`); + + const actions: Record Promise> = { + up: () => stack.up({ onOutput, ...config.options }).then((r) => r.stdout), + update: () => + stack.up({ onOutput, ...config.options }).then((r) => r.stdout), + refresh: () => + stack.refresh({ onOutput, ...config.options }).then((r) => r.stdout), + destroy: () => + stack.destroy({ onOutput, ...config.options }).then((r) => r.stdout), + preview: async () => { + const { stdout, stderr } = await stack.preview(config.options); + onOutput(stdout); + onOutput(stderr); + return stdout; + }, + install: () => Promise.reject("Unreachable code. If you encounter this error, please file a bug at https://github.com/pulumi/actions/issues/new/choose"), // unreachable. + }; + + core.debug(`Running action ${config.command}`); + const output = await actions[config.command](); + core.debug(`Done running action ${config.command}`); + + core.setOutput('output', output); + + const outputs = await stack.outputs(); + + for (const [outKey, outExport] of Object.entries(outputs)) { + core.setOutput(outKey, outExport.value); + if (outExport.secret) { + core.setSecret(outExport.value); + } + } + + if (config.commentOnPr && config.isPullRequest) { + core.debug(`Commenting on pull request`); + invariant(config.githubToken, 'github-token is missing.'); + handlePullRequestMessage(config, projectName, output); + } + + if (config.remove && config.command === 'destroy') { + stack.workspace.removeStack(stack.name) + } + + if(!disableCache) { + await cachePlugins(stack); + } + + core.endGroup(); +}; + +// NB: Another approach would be to use cacheDir, which caches an +// entire directory. Using cacheDir, it's harder to version +// individual plugins separate from the whole directory. +const cachePlugins = async (stack: Stack): Promise => { + const plugins = await stack.workspace.listPlugins(); + const cacheAll = plugins.map((plugin: PluginInfo) => { + return cacheFile(plugin.path, plugin.name, plugin.name, plugin.version); + }); + return Promise.all(cacheAll); +}; \ No newline at end of file