Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache Plugins on Runner between Runs #841

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `./`.

Expand Down Expand Up @@ -105,8 +106,8 @@ The action can be configured with the following arguments:
`Pulumi.<stack-name>.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"

Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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(() => {
Expand Down
7 changes: 4 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof command>;
Expand Down
106 changes: 4 additions & 102 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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<Commands, () => Promise<string>> = {
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 () => {
Expand All @@ -116,4 +18,4 @@ const main = async () => {
core.setFailed(err.message);
}
}
})();
})();
137 changes: 137 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {

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<Commands, () => Promise<string>> = {
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<string[]> => {
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);
};