diff --git a/README.md b/README.md index 156b54a..24ab4f9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,30 @@ Override parameters using a local JSON file: `"file:///${{ github.workspace }}/p > You can learn more about [AWS CloudFormation](https://aws.amazon.com/cloudformation/) +#### envs-prefix-for-parameter-overrides (OPTIONAL) + +You can also override parameter values using environment variables with prefixed keys. Use `envs-prefix-for-parameter-overrides` parameter to define a prefix you want action to filter out and combine with other parameters while deploying. + +The biggest advantage of this approach is that you don't need to think about escaping your variables (as long as Github UI/YAML accepts it). + +The prefix of your choice will be stripped. + +Example: +```yaml +- name: Deploy to AWS CloudFormation + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStack + template: myStack.yaml + parameter-overrides: "MyParam1=myValue,MyParam2=${{ secrets.MY_SECRET_VALUE }}" + envs-prefix-for-parameter-overrides: CFD_ + env: + CFD_MyParam3: some value, that is automatically escaped + CFD_MyParam4: ${{ vars.MY_VAR_VALUE }} +``` + +This example will result in 4 parameter overrides: MyParam1, MyParam2, MyParam3 and MyParam4. The last two will be taken from environment variables. + ## Credentials and Region This action relies on the [default behavior of the AWS SDK for Javascript](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html) to determine AWS credentials and region. diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 9ae26f2..ed2bc1a 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1389,6 +1389,376 @@ describe('Deploy CloudFormation Stack', () => { expect(mockCfnClient).toHaveReceivedCommandTimes(ExecuteChangeSetCommand, 0) }) + test('deploys the stack with prefixed envs', async () => { + const inputs: Inputs = { + name: 'MockStack', + template: 'template.yaml', + capabilities: 'CAPABILITY_IAM', + 'parameter-overrides': 'AdminEmail=no-reply@amazon.com', + 'envs-prefix-for-parameter-overrides': 'CFD_', + 'no-fail-on-empty-changeset': '1' + } + + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + return inputs[name] + }) + + mockCfnClient + .reset() + .on(DescribeStacksCommand) + .resolvesOnce({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: 'CREATE_COMPLETE' + } + ] + }) + .resolves({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: StackStatus.UPDATE_COMPLETE + } + ] + }) + .on(CreateChangeSetCommand) + .resolves({}) + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeChangeSetCommand) + .resolves({ Status: ChangeSetStatus.CREATE_COMPLETE }) + + process.env = Object.assign(process.env, { CFD_AdminNickname: 'root' }) + + await run() + + delete process.env.CFD_AdminNickname + + expect(core.setFailed).toHaveBeenCalledTimes(0) + expect(mockCfnClient).toHaveReceivedCommandTimes(DescribeStacksCommand, 3) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 1, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 2, + CreateChangeSetCommand, + { + Capabilities: ['CAPABILITY_IAM'], + NotificationARNs: undefined, + Parameters: [ + { + ParameterKey: 'AdminEmail', + ParameterValue: 'no-reply@amazon.com' + }, + { + ParameterKey: 'AdminNickname', + ParameterValue: 'root' + } + ], + ResourceTypes: undefined, + RoleARN: undefined, + RollbackConfiguration: undefined, + StackName: 'MockStack', + Tags: undefined, + TemplateBody: mockTemplate, + TemplateURL: undefined + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 3, + DescribeChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 4, + ExecuteChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 5, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 6, + DescribeStacksCommand, + { + StackName: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896' + } + ) + }) + + test('deploys the stack with prefixed envs but no envs are passed', async () => { + const inputs: Inputs = { + name: 'MockStack', + template: 'template.yaml', + capabilities: 'CAPABILITY_IAM', + 'parameter-overrides': 'AdminEmail=no-reply@amazon.com', + 'envs-prefix-for-parameter-overrides': 'CFD_', + 'no-fail-on-empty-changeset': '1' + } + + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + return inputs[name] + }) + + mockCfnClient + .reset() + .on(DescribeStacksCommand) + .resolvesOnce({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: 'CREATE_COMPLETE' + } + ] + }) + .resolves({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: StackStatus.UPDATE_COMPLETE + } + ] + }) + .on(CreateChangeSetCommand) + .resolves({}) + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeChangeSetCommand) + .resolves({ Status: ChangeSetStatus.CREATE_COMPLETE }) + + await run() + + delete process.env.CFD_AdminNickname + + expect(core.setFailed).toHaveBeenCalledTimes(0) + expect(mockCfnClient).toHaveReceivedCommandTimes(DescribeStacksCommand, 3) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 1, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 2, + CreateChangeSetCommand, + { + Capabilities: ['CAPABILITY_IAM'], + NotificationARNs: undefined, + Parameters: [ + { + ParameterKey: 'AdminEmail', + ParameterValue: 'no-reply@amazon.com' + } + ], + ResourceTypes: undefined, + RoleARN: undefined, + RollbackConfiguration: undefined, + StackName: 'MockStack', + Tags: undefined, + TemplateBody: mockTemplate, + TemplateURL: undefined + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 3, + DescribeChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 4, + ExecuteChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 5, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 6, + DescribeStacksCommand, + { + StackName: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896' + } + ) + }) + + test('deploys the stack with prefixed envs but no other parameter overrides are passed', async () => { + const inputs: Inputs = { + name: 'MockStack', + template: 'template.yaml', + capabilities: 'CAPABILITY_IAM', + 'envs-prefix-for-parameter-overrides': 'CFD_', + 'no-fail-on-empty-changeset': '1' + } + + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + return inputs[name] + }) + + mockCfnClient + .reset() + .on(DescribeStacksCommand) + .resolvesOnce({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: 'CREATE_COMPLETE' + } + ] + }) + .resolves({ + Stacks: [ + { + StackId: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896', + Tags: [], + Outputs: [], + StackStatusReason: '', + CreationTime: new Date('2013-08-23T01:02:15.422Z'), + Capabilities: [], + StackName: 'MockStack', + StackStatus: StackStatus.UPDATE_COMPLETE + } + ] + }) + .on(CreateChangeSetCommand) + .resolves({}) + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeChangeSetCommand) + .resolves({ Status: ChangeSetStatus.CREATE_COMPLETE }) + + process.env = Object.assign(process.env, { CFD_AdminNickname: 'root' }) + + await run() + + delete process.env.CFD_AdminNickname + + expect(core.setFailed).toHaveBeenCalledTimes(0) + expect(mockCfnClient).toHaveReceivedCommandTimes(DescribeStacksCommand, 3) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 1, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 2, + CreateChangeSetCommand, + { + Capabilities: ['CAPABILITY_IAM'], + NotificationARNs: undefined, + Parameters: [ + { + ParameterKey: 'AdminNickname', + ParameterValue: 'root' + } + ], + ResourceTypes: undefined, + RoleARN: undefined, + RollbackConfiguration: undefined, + StackName: 'MockStack', + Tags: undefined, + TemplateBody: mockTemplate, + TemplateURL: undefined + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 3, + DescribeChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 4, + ExecuteChangeSetCommand, + { + ChangeSetName: 'MockStack-CS', + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 5, + DescribeStacksCommand, + { + StackName: 'MockStack' + } + ) + expect(mockCfnClient).toHaveReceivedNthCommandWith( + 6, + DescribeStacksCommand, + { + StackName: + 'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896' + } + ) + }) + test('error is caught by core.setFailed', async () => { mockCfnClient.reset().on(DescribeStacksCommand).rejects(new Error()) diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 3b740e9..97cfbae 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -1,4 +1,9 @@ -import { parseTags, isUrl, parseParameters } from '../src/utils' +import { + parseTags, + isUrl, + parseParameters, + parseParametersFromEnvs +} from '../src/utils' import * as path from 'path' jest.mock('@actions/core') @@ -119,6 +124,30 @@ describe('Parse Parameters', () => { ]) }) + test('returns parameters list from envs if envsPrefixForParameterOverrides is defined', async () => { + const mockEnvs = { + CFD_MyParam1: 'myValue1', + CFD_MyParam2: 'myValue2', + USER: 'test', + PATH: '/bin:/usr/local/bin', + HOME: '/home/test', + AWS_PAGER: '' + } + const envsPrefix = 'CFD_' + + const json = parseParametersFromEnvs(envsPrefix, mockEnvs) + expect(json).toEqual([ + { + ParameterKey: 'MyParam1', + ParameterValue: 'myValue1' + }, + { + ParameterKey: 'MyParam2', + ParameterValue: 'myValue2' + } + ]) + }) + test('throws error if file is not found', async () => { const filename = 'file://' + path.join(__dirname, 'params.tezt.json') expect(() => parseParameters(filename)).toThrow() diff --git a/action.yml b/action.yml index 4742220..a403816 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,9 @@ inputs: parameter-overrides: description: 'The parameters to override in the stack inputs. You can pass a comma-delimited list or a file URL. Comma-delimited list has each entry formatted as = or =",". A JSON file can be a local file with a "file://" prefix or remote URL. The file should look like: [ { "ParameterKey": "KeyPairName", "ParameterValue": "MyKey" }]' required: false + envs-prefix-for-parameter-overrides: + description: 'Set environment variable key prefix to filter out and use as parameters to override. The prefix will be stripped.' + default: "" no-execute-changeset: description: "Indicates whether to execute to the change set or have it reviewed. Default to '0' (will execute the change set)" required: false diff --git a/dist/index.js b/dist/index.js index b9ce8ce..43263cf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -242,6 +242,9 @@ function run() { const parameterOverrides = core.getInput('parameter-overrides', { required: false }); + const envsPrefixForParameterOverrides = core.getInput('envs-prefix-for-parameter-overrides', { + required: false + }); const noEmptyChangeSet = !!+core.getInput('no-fail-on-empty-changeset', { required: false }); @@ -316,6 +319,12 @@ function run() { if (parameterOverrides) { params.Parameters = (0, utils_1.parseParameters)(parameterOverrides.trim()); } + if (envsPrefixForParameterOverrides) { + const envParameters = (0, utils_1.parseParametersFromEnvs)(envsPrefixForParameterOverrides, process.env); + params.Parameters = params.Parameters + ? [...params.Parameters, ...envParameters] + : envParameters; + } const stackId = yield (0, deploy_1.deployStack)(cfn, params, changeSetName ? changeSetName : `${params.StackName}-CS`, noEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet); core.setOutput('stack-id', stackId || 'UNKNOWN'); if (stackId) { @@ -371,7 +380,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.configureProxy = exports.parseParameters = exports.parseNumber = exports.parseString = exports.parseARNs = exports.parseTags = exports.isUrl = void 0; +exports.configureProxy = exports.parseParametersFromEnvs = exports.parseParameters = exports.parseNumber = exports.parseString = exports.parseARNs = exports.parseTags = exports.isUrl = void 0; const fs = __importStar(__nccwpck_require__(57147)); const https_proxy_agent_1 = __nccwpck_require__(77219); function isUrl(s) { @@ -443,6 +452,16 @@ function parseParameters(parameterOverrides) { }); } exports.parseParameters = parseParameters; +function parseParametersFromEnvs(prefix, envs) { + const parameters = Object.keys(envs) + .filter(key => key.startsWith(prefix)) + .map(key => ({ + ParameterKey: key.substring(prefix.length), + ParameterValue: envs[key] + })); + return parameters; +} +exports.parseParametersFromEnvs = parseParametersFromEnvs; function configureProxy(proxyServer) { const proxyFromEnv = process.env.HTTP_PROXY || process.env.http_proxy; if (proxyFromEnv || proxyServer) { diff --git a/src/main.ts b/src/main.ts index 143bb23..516b9d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,8 @@ import { parseNumber, parseARNs, parseParameters, - configureProxy + configureProxy, + parseParametersFromEnvs } from './utils' import { NodeHttpHandler } from '@smithy/node-http-handler' @@ -52,6 +53,12 @@ export async function run(): Promise { const parameterOverrides = core.getInput('parameter-overrides', { required: false }) + const envsPrefixForParameterOverrides = core.getInput( + 'envs-prefix-for-parameter-overrides', + { + required: false + } + ) const noEmptyChangeSet = !!+core.getInput('no-fail-on-empty-changeset', { required: false }) @@ -150,6 +157,16 @@ export async function run(): Promise { params.Parameters = parseParameters(parameterOverrides.trim()) } + if (envsPrefixForParameterOverrides) { + const envParameters = parseParametersFromEnvs( + envsPrefixForParameterOverrides, + process.env + ) + params.Parameters = params.Parameters + ? [...params.Parameters, ...envParameters] + : envParameters + } + const stackId = await deployStack( cfn, params, diff --git a/src/utils.ts b/src/utils.ts index d424382..e07e3bf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -78,6 +78,21 @@ export function parseParameters(parameterOverrides: string): Parameter[] { }) } +type Envs = { [k: string]: string | undefined } + +export function parseParametersFromEnvs( + prefix: string, + envs: Envs +): Parameter[] { + const parameters: Parameter[] = Object.keys(envs) + .filter(key => key.startsWith(prefix)) + .map(key => ({ + ParameterKey: key.substring(prefix.length), + ParameterValue: envs[key] + })) + return parameters +} + export function configureProxy( proxyServer: string | undefined ): HttpsProxyAgent | undefined {