serverless-ssm-publish
Version:
Serverless Framework plugin to publish data to AWS SSM Parameter Store
306 lines (248 loc) • 12.1 kB
text/typescript
import { CloudFormation, SSM } from 'aws-sdk';
import chalk from 'chalk';
import yaml from 'js-yaml';
import markdownTable from 'markdown-table';
import * as util from 'util';
import { ServerlessInstance, SSMParam, SSMParamCloudFormation, SSMParamWithValue } from './types';
import { compareParams, evaluateEnabled, validateParams } from './util';
const unsupportedRegionPrefixes = [
'ap-east-1', // Hong Kong - region disabled by default
'me-south-1', // Bahrain - region disabled by default
];
class ServerlessSSMPublish {
// Serverless specific properties
public hooks: { [i: string]: () => void };
public commands: object;
private readonly serverless: ServerlessInstance;
private readonly provider: any; // tslint:disable-line:no-any
// AWS SDK resources
private ssm: SSM; // tslint:disable-line:no-any
// SSM Publish internal properties
private initialized = false;
private enabled: boolean;
private stackName: string;
// SSM Publish custom variables
private region: string;
private params: SSMParamWithValue[];
private nonExistingParams: SSMParamWithValue[];
private existingChangedParams: SSMParamWithValue[];
private existingUnchangedParams: SSMParamWithValue[];
private cloudFormationOutput: SSMParamWithValue[] | undefined;
private static prettifyCFOutput(stackResult: CloudFormation.DescribeStacksOutput): SSMParamWithValue[] {
if (!stackResult.Stacks) return [];
const stackOutput = stackResult.Stacks[0].Outputs;
if (!stackOutput) return [];
const formattedParams = stackOutput
// ensure output matches our formatting
.map((output) => ({
path: output.OutputKey || '',
value: output.OutputValue || '',
description: output.Description,
}));
return formattedParams || [];
}
/**
* Constructor
* @param serverless
*/
constructor(serverless: ServerlessInstance) {
this.serverless = serverless;
// Bind plugin to aws provider. It will not run on a different provider.
this.provider = this.serverless.getProvider('aws');
// Optional
this.commands = {
ssmPublish: {
usage: 'Helps you publish params to SSM',
lifecycleEvents: [
'checkIfParamsExist',
'upsertParams',
],
},
};
this.hooks = {
// Pre & post hooks
'after:ssmPublish:upsertParams': this.hookWrapper.bind(this, this.summary.bind(this)), // tslint:disable-line:no-unsafe-any
// Actual lifecycle event handling
'ssmPublish:checkIfParamsExist': this.hookWrapper.bind(this, this.getAndCheckParams.bind(this)), // tslint:disable-line:no-unsafe-any
'ssmPublish:upsertParams': this.hookWrapper.bind(this, this.updateParams.bind(this)), // tslint:disable-line:no-unsafe-any
// Serverless lifecycle events
'after:deploy:deploy': async () => { // check if this is the best place to call our plugin
await this.hookWrapper.bind(this, this.getAndCheckParams.bind(this))(); // tslint:disable-line:no-unsafe-any
await this.hookWrapper.bind(this, this.updateParams.bind(this))(); // tslint:disable-line:no-unsafe-any
this.hookWrapper.bind(this, this.summary.bind(this))(); // tslint:disable-line:no-unsafe-any
},
};
}
/**
* Wrapper for lifecycle function, initializes variables and checks if enabled.
* @param lifecycleFunc lifecycle function that actually does desired action
*/
public async hookWrapper(lifecycleFunc: any) { // tslint:disable-line:no-any
await this.initializeVariables();
if (!this.enabled) {
this.log('serverless-ssm-publish: SSM Publish is disabled.');
} else {
return lifecycleFunc.call(this); // tslint:disable-line:no-unsafe-any
}
}
/**
* Goes through custom ssm publish properties and initializes local variables.
*/
private async initializeVariables(): Promise<void> {
if (!this.initialized) {
this.enabled = evaluateEnabled(this.serverless.service.custom, this.throwError.bind(this)); // tslint:disable-line:no-unsafe-any
if (this.enabled) {
const credentials = this.provider.getCredentials(); // tslint:disable-line:no-unsafe-any
// Extract plugin variables
this.region = this.serverless.service.provider.region;
this.stackName = this.serverless.service.provider.stackName ?? util.format('%s-%s',
this.serverless.service.getServiceName(),
this.serverless.getProvider('aws').getStage(),
);
// Initialize AWS SDK clients
const credentialsWithRegion = { ...credentials, region: this.region };
this.ssm = new this.provider.sdk.SSM(credentialsWithRegion); // tslint:disable-line:no-unsafe-any
// Validate plugin variables
const yamlDefinedParams = validateParams(this.serverless.service.custom.ssmPublish.params, this.throwError.bind(this), this.log.bind(this)) || []; // tslint:disable-line:no-unsafe-any
// Retrieve Cloud Formation output if necessary
if (yamlDefinedParams.some((param: SSMParamCloudFormation) => param.source)) {
this.logIfDebug('Retrieving Cloud Formation Outputs');
this.cloudFormationOutput = await this.retrieveAndFormatCloudFormationOutput();
}
// Merge cloud formation output with yaml defined values
const checkIfParamHasValue = (param: SSMParam): param is SSMParamWithValue => param.hasOwnProperty('value');
const mergedParams = yamlDefinedParams.map((slsParam): SSMParamWithValue => {
if (checkIfParamHasValue(slsParam)) return slsParam;
const foundCloudFormationParam = this.cloudFormationOutput?.find((cloudFormationParam) => cloudFormationParam.path === slsParam.source);
if (!foundCloudFormationParam) {
this.throwError(`No Cloud Formation Output found for source ${slsParam.source}`);
throw new Error(`No Cloud Formation Output found for source ${slsParam.source}`); // Throwing again as typescript won't recognise throwError as throwing
}
return { ...slsParam, value: foundCloudFormationParam.value, description: foundCloudFormationParam.description || slsParam.description };
});
// Put params on this for following logic
this.params = [...mergedParams]; // tslint:disable-line:no-unsafe-any
unsupportedRegionPrefixes.forEach((unsupportedRegionPrefix) => {
if (this.region.startsWith(unsupportedRegionPrefix)) {
this.log(chalk.bold.yellow(`The configured region ${this.region} does not support SSM. Plugin disabled`));
this.enabled = false;
}
});
}
this.initialized = true;
}
}
/**
* Checks whether parameters exist in SSM and if they've been changed
* Stores arrays of changed/unchanged/new Parameters on class
* We need to account for SSM.GetParameters taking a max of 10 names
*/
private async getAndCheckParams() {
if (!this.params) return;
const chunkArray = ((array: SSMParam[], arraySize: number): SSMParam[][] => {
const arrayChunks: SSMParam[][] = [];
for (let i = 0; i < array.length; i += arraySize) {
arrayChunks.push(array.slice(i, arraySize + i));
}
return arrayChunks;
});
const getParameters = async (params: SSMParam[]) => this.ssm.getParameters({ Names: params.map((param) => param.path), WithDecryption: true }).promise();
const paramsToCheck = chunkArray(this.params, 10); // tslint:disable-line:no-magic-numbers
const retrievedParameterArray = await Promise.all(paramsToCheck.map(async (paramGroup: SSMParam[]) => getParameters(paramGroup)));
const foundParams = retrievedParameterArray.map((result) => result.Parameters).reduce((acc, curr) => [...acc ? acc : [], ...curr ? curr : []], []);
const invalidOrNewParams = retrievedParameterArray.map((result) => result.InvalidParameters).reduce((acc, curr) => [...acc ? acc : [], ...curr ? curr : []], []);
if (invalidOrNewParams?.length) this.log(chalk.yellow(`New or invalid parameters present:\n\t${invalidOrNewParams.join('\n\t')}`));
const { nonExistingParams, existingChangedParams, existingUnchangedParams } = compareParams(this.params, foundParams);
this.logIfDebug(`New param paths:\n\t${nonExistingParams.map((param) => param.path).join('\n\t')}`);
this.logIfDebug(`Changed param paths:\n\t${existingChangedParams.map((param) => param.path).join('\n\t')}`);
this.logIfDebug(`Unchanged param paths:\n\t${existingUnchangedParams.map((param) => param.path).join('\n\t')}`);
this.nonExistingParams = nonExistingParams;
this.existingChangedParams = existingChangedParams;
this.existingUnchangedParams = existingUnchangedParams;
}
/**
* Makes putParameter request for all changed/new parameters
*/
private async updateParams() {
const toUpdate = [...this.nonExistingParams, ...this.existingChangedParams];
const putResults = await Promise.all(toUpdate.map(async (param: SSMParamWithValue) => this.ssm.putParameter(
{
Name: param.path,
Description: param.description || `Placed by ${this.serverless.service.getServiceName()} - serverless-ssm-plugin`,
Value: typeof param.value === 'string'
? param.value
: param.type === 'StringList' && Array.isArray(param.value) && param.value.every((item) => typeof item === 'string')
? param.value.join(',')
: yaml.safeDump(param.value),
Overwrite: true,
Type: param.type ? param.type : param.secure ? 'SecureString' : 'String',
}).promise(),
));
this.logIfDebug(`SSM Put Results:\n${chalk.green(
putResults.length > 0
? (markdownTable([
['Path', 'Secure', 'Version', 'Tier', 'Type'],
...putResults.map(({ Version, Tier }, i) =>
([
toUpdate[i].path,
toUpdate[i].secure,
Version ? Version : '',
Tier ? Tier : '',
toUpdate[i].type,
]) as string[]),
]))
: 'No updates performed.',
)}`);
}
private async retrieveAndFormatCloudFormationOutput(): Promise<SSMParamWithValue[]> {
const stackResult = await this.fetchCFOutput();
return ServerlessSSMPublish.prettifyCFOutput(stackResult);
}
private async fetchCFOutput(): Promise<CloudFormation.DescribeStacksOutput> {
return this.serverless.getProvider('aws').request(
'CloudFormation',
'describeStacks',
{ StackName: this.stackName },
this.serverless.getProvider('aws').getStage(),
this.serverless.getProvider('aws').getRegion(),
);
}
/**
* Logs message with prefix
* @param message message to be printed
*/
private log(message: string): void {
this.serverless.cli.log(`[serverless-ssm-publish]: ${message}`);
}
/**
* Logs message with prefix if SLS_DEBUG is set
* @param message message to be printed
*/
private logIfDebug(message: string): void {
if (process.env.SLS_DEBUG) {
this.serverless.cli.log(`[serverless-ssm-publish]: ${message}`);
}
}
/**
* Throws error using Serverless formatting
* @param message message to be printed
*/
private throwError(message: string): void {
throw new this.serverless.classes.Error(`[serverless-ssm-publish]: ${message}`); // tslint:disable-line:no-unsafe-any
}
private summary() {
this.log(`SSM Publish Summary:\n${chalk.bold.green(
markdownTable([
[
`Created (${this.nonExistingParams.length})`,
`Updated (${this.existingChangedParams.length})`,
`Unchanged (${this.existingUnchangedParams.length})`,
],
...this.nonExistingParams.map((p) => ([p.path, '', ''])),
...this.existingChangedParams.map((p) => (['', p.path, ''])),
...this.existingUnchangedParams.map((p) => (['', '', p.path])),
]),
)}`);
}
}
module.exports = ServerlessSSMPublish;