UNPKG

serverless-ssm-publish

Version:

Serverless Framework plugin to publish data to AWS SSM Parameter Store

306 lines (248 loc) 12.1 kB
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;