Skip to content

Commit

Permalink
Add support for overwrite-mode
Browse files Browse the repository at this point in the history
  • Loading branch information
jromero-pg committed Feb 8, 2023
1 parent 54b6aff commit 8a21d6d
Show file tree
Hide file tree
Showing 10 changed files with 365 additions and 81 deletions.
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,31 @@ To use the action, add a step to your workflow that uses the following syntax.
### Parameters
- `secret-ids`: Secret ARNS, names, and name prefixes.

By default, the step creates each environment variable name from the secret name, transformed to include only uppercase letters, numbers, and underscores, and so that it doesn't begin with a number.
By default, the step creates each environment variable name from the secret name, transformed to include only uppercase letters, numbers, and underscores, and so that it doesn't begin with a number.

To set the environment variable name, enter it before the secret ID, followed by a comma. For example `ENV_VAR_1, secretId` creates an environment variable named **ENV_VAR_1** from the secret `secretId`.
To set the environment variable name, enter it before the secret ID, followed by a comma. For example `ENV_VAR_1, secretId` creates an environment variable named **ENV_VAR_1** from the secret `secretId`.

The environment variable name can consist of uppercase letters, numbers, and underscores.
The environment variable name can consist of uppercase letters, numbers, and underscores.

To use a prefix, enter at least three characters followed by an asterisk. For example `dev*` matches all secrets with a name beginning in **dev**. The maximum number of matching secrets that can be retrieved is 100. If you set the variable name, and the prefix matches multiple secrets, then the action fails.

- `overwrite-mode`

(Optional - default `error`) By default, the action prevents overwriting secrets.

The following modes are supported:
- `error` - Error when a secret is already set.
- `warn` - Warn when a secret is overwritten.
- `silent` - Allow for secrets to be overwritten silently.​

To use a prefix, enter at least three characters followed by an asterisk. For example `dev*` matches all secrets with a name beginning in **dev**. The maximum number of matching secrets that can be retrieved is 100. If you set the variable name, and the prefix matches multiple secrets, then the action fails.
- `parse-json-secrets`

(Optional - default false) By default, the action sets the environment variable value to the entire JSON string in the secret value.
(Optional - default false) By default, the action sets the environment variable value to the entire JSON string in the secret value.

Set `parse-json-secrets` to `true` to create environment variables for each key/value pair in the JSON.
Set `parse-json-secrets` to `true` to create environment variables for each key/value pair in the JSON.

Note that if the JSON uses case-sensitive keys such as "name" and "Name", the action will have duplicate name conflicts. In this case, set `parse-json-secrets` to `false` and parse the JSON secret value separately.

Note that if the JSON uses case-sensitive keys such as "name" and "Name", the action will have duplicate name conflicts. In this case, set `parse-json-secrets` to `false` and parse the JSON secret value separately.
### Examples
**Example 1: Get secrets by name and by ARN**
Expand Down
176 changes: 145 additions & 31 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ jest.mock('@actions/core', () => {
return {
getMultilineInput: jest.fn((name: string, options?: core.InputOptions) => [TEST_NAME, TEST_INPUT_3, TEST_ARN_INPUT] ),
getBooleanInput: jest.fn((name: string, options?: core.InputOptions) => true),
getInput: jest.fn((name: string, options?: core.InputOptions) => ''),
setFailed: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warning: jest.fn(),
exportVariable: jest.fn((name: string, val: string) => process.env[name] = val),
setSecret: jest.fn(),
};
Expand Down Expand Up @@ -157,36 +159,148 @@ describe('Test main action', () => {
expect(core.setFailed).toHaveBeenCalledTimes(1);
});

test('Fails the action when multiple secrets exported the same variable name', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1})
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, {SecretId: TEST_NAME_2 })
.resolves({ Name: TEST_NAME_2, SecretString: SECRET_2 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_3 })
.resolves({ Name: TEST_NAME_3, SecretString: SECRET_3 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(GetSecretValueCommand) // default
.resolves({Name: "DefaultName", SecretString: "Default"})
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: "TEST/SECRET/2"
},
{
Name: "TEST/SECRET@2"
}
]
});
describe('overwrite-mode', () => {
test('default - fails the action when multiple secrets exported the same variable name', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(GetSecretValueCommand) // default
.resolves({ Name: "DefaultName", SecretString: "Default" })
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: "TEST/SECRET/2"
},
{
Name: "TEST/SECRET@2"
}
]
});

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce([TEST_NAME]);
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('');

await run();
expect(core.setFailed).toHaveBeenCalledTimes(1);
});
await run();
expect(core.setFailed).toHaveBeenCalledTimes(1);
expect(core.setFailed)
.toHaveBeenCalledWith("Failed to fetch secret: 'TEST/SECRET@2'. Reason: Error: The environment name 'TEST_SECRET_2' is already in use. Please use an alias to ensure that each secret has a unique environment name.");
});

test('error - fails the action when multiple secrets exported the same variable name', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(GetSecretValueCommand) // default
.resolves({ Name: "DefaultName", SecretString: "Default" })
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: "TEST/SECRET/2"
},
{
Name: "TEST/SECRET@2"
}
]
});

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce([TEST_NAME]);
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('error');

await run();
expect(core.setFailed).toHaveBeenCalledTimes(1);
expect(core.setFailed)
.toHaveBeenCalledWith("Failed to fetch secret: 'TEST/SECRET@2'. Reason: Error: The environment name 'TEST_SECRET_2' is already in use. Please use an alias to ensure that each secret has a unique environment name.");
});

test('warn - warns when multiple secrets exported the same variable name', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(GetSecretValueCommand) // default
.resolves({ Name: "DefaultName", SecretString: "Default" })
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: "TEST/SECRET/2"
},
{
Name: "TEST/SECRET@2"
}
]
});

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce([TEST_NAME]);
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('warn');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.warning)
.toHaveBeenCalledWith("The environment name 'TEST_SECRET_2' is already in use. The value will be overwritten.");
});

test('silent - warns when multiple secrets exported the same variable name', async () => {
smMockClient
.on(GetSecretValueCommand, { SecretId: TEST_NAME_1 })
.resolves({ Name: TEST_NAME_1, SecretString: SECRET_1 })
.on(GetSecretValueCommand, { SecretId: TEST_NAME_2 })
.on(GetSecretValueCommand, { // Retrieve arn secret
SecretId: TEST_ARN_1,
})
.resolves({
Name: TEST_NAME_4,
SecretString: SECRET_4
})
.on(GetSecretValueCommand) // default
.resolves({ Name: "DefaultName", SecretString: "Default" })
.on(ListSecretsCommand)
.resolves({
SecretList: [
{
Name: "TEST/SECRET/2"
},
{
Name: "TEST/SECRET@2"
}
]
});

jest.spyOn(core, 'getMultilineInput').mockReturnValueOnce([TEST_NAME]);
jest.spyOn(core, 'getBooleanInput').mockReturnValueOnce(true);
jest.spyOn(core, 'getInput').mockReturnValueOnce('silent');

await run();
expect(core.setFailed).not.toHaveBeenCalled();
expect(core.warning).not.toHaveBeenCalled();
});
})
});
36 changes: 28 additions & 8 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
injectSecret,
isSecretArn,
extractAliasAndSecretIdFromInput,
transformToValidEnvName
transformToValidEnvName,
validateOverwriteMode,
OverwriteMode
} from "../src/utils";

import { CLEANUP_NAME, LIST_SECRETS_MAX_RESULTS } from "../src/constants";
Expand Down Expand Up @@ -288,38 +290,38 @@ describe('Test secret parsing and handling', () => {
* Test: injectSecret()
*/
test('Stores a simple secret', () => {
injectSecret(TEST_NAME, undefined, TEST_VALUE, false);
injectSecret(TEST_NAME, undefined, TEST_VALUE, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith(TEST_ENV_NAME, TEST_VALUE);
});

test('Stores a simple secret with alias', () => {
injectSecret(TEST_NAME, 'ALIAS_1', TEST_VALUE, false);
injectSecret(TEST_NAME, 'ALIAS_1', TEST_VALUE, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith('ALIAS_1', TEST_VALUE);
});

test('Stores a JSON secret as string when parseJson is false', () => {
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, false);
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
expect(core.exportVariable).toHaveBeenCalledTimes(1);
expect(core.exportVariable).toHaveBeenCalledWith(TEST_ENV_NAME, SIMPLE_JSON_SECRET);
});

test('Throws an error if reserved name is used', () => {
expect(() => {
injectSecret(CLEANUP_NAME, undefined, TEST_VALUE, false);
injectSecret(CLEANUP_NAME, undefined, TEST_VALUE, {parseJsonSecrets: false, overwriteMode: OverwriteMode.ERROR});
}).toThrow();
});

test('Stores a variable for each JSON key value when parseJson is true', () => {
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, true);
injectSecret(TEST_NAME, undefined, SIMPLE_JSON_SECRET, {parseJsonSecrets: true, overwriteMode: OverwriteMode.ERROR});
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_API_KEY', 'testkey');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_USER', 'testuser');
});

test('Stores a variable for nested JSON key values when parseJson is true', () => {
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, true);
injectSecret(TEST_NAME, undefined, NESTED_JSON_SECRET, {parseJsonSecrets: true, overwriteMode: OverwriteMode.ERROR});
expect(core.setSecret).toHaveBeenCalledTimes(7);
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_HOST', '127.0.0.1');
expect(core.exportVariable).toHaveBeenCalledWith('TEST_SECRET_PORT', '3600');
Expand Down Expand Up @@ -408,4 +410,22 @@ describe('Test secret parsing and handling', () => {
test('Test valid nested JSON { "a": "yes", "options": { "opt_a": "yes", "opt_b": "no"} } ', () => {
expect(isJSONString('{ "a": "yes", "options": { "opt_a": "yes", "opt_b": "no"} }')).toBe(true)
});
});
});

describe('#validateOverwriteMode', () => {
test('Defaults to ERROR', () => {
expect(validateOverwriteMode('')).toBe(OverwriteMode.ERROR);
})

test('Returns valid values', () => {
expect(validateOverwriteMode('error')).toBe(OverwriteMode.ERROR);
expect(validateOverwriteMode('silent')).toBe(OverwriteMode.SILENT);
expect(validateOverwriteMode('warn')).toBe(OverwriteMode.WARN);
})

test('Throws error when value is invalid', () => {
expect(() => {
validateOverwriteMode('invalid')
}).toThrowError("Invalid overwrite mode 'invalid'.")
})
})
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ inputs:
description: '(Optional) If true, JSON secrets will be deserialized, creating a secret environment variable for each key-value pair.'
required: false
default: 'false'
overwrite-mode:
description: '(Optional) Define how to handle overwriting secrets.'
required: false
default: 'error'
runs:
using: 'node16'
main: 'dist/index.js'
Expand Down
Loading

0 comments on commit 8a21d6d

Please sign in to comment.