Created
September 27, 2020 19:06
-
-
Save spalladino/12026da83c6a8bd04b20ebf37893602b to your computer and use it in GitHub Desktop.
Deploy a subset of AWS SAM functions bypassing CloudFormation for a faster development cycle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /usr/bin/env node | |
const TEMPLATE_FILENAME = 'template.yaml'; | |
const WEBPACK_CONFIG = './api/webpack.config.js'; | |
const WEBPACK_CONTEXT = './api'; | |
const STACKNAME = process.env.STACKNAME; | |
const { Lambda, CloudFormation } = require('aws-sdk'); | |
const { yamlParse } = require('yaml-cfn'); | |
const { readFileSync } = require('fs'); | |
const { difference, keys, uniq, pick, pickBy, toPairs } = require('lodash'); | |
const { basename, resolve, dirname, extname } = require('path'); | |
const { promisify } = require('util'); | |
const exec = promisify(require('child_process').exec); | |
const webpack = require('webpack'); | |
function loadFunctions(fname) { | |
// Parse the template file and extract anything that is a Serverless::Function | |
// We compare the logical name against the filter provided by the user | |
const template = yamlParse(readFileSync(TEMPLATE_FILENAME, 'utf8')); | |
const functions = toPairs(pickBy(template.Resources, (resource, name) => ( | |
name.toLowerCase().includes(fname.toLowerCase()) && resource.Type === 'AWS::Serverless::Function' | |
))).map(([name, fn]) => ({ Name: name, ...fn.Properties })); | |
if (functions.length === 0) { | |
console.error(`No functions found to deploy that match ${fname}`); | |
return; | |
} | |
console.error(`Found ${functions.length} function(s) to deploy:\n${functions.map(f => `- ${f.Name}`).join('\n')}\n`); | |
return functions; | |
} | |
async function compileFunctions(functions) { | |
if (process.env.SKIP_COMPILE) { | |
console.error(`Skipping compilation\n`); | |
return; | |
} | |
// Load config and set context to backend folder | |
const config = require(resolve(WEBPACK_CONFIG)); | |
config.context = resolve(WEBPACK_CONTEXT); | |
// Remove unneeded entrypoints, we only care about the functions we are deploying | |
const entryPoints = uniq(functions.map(f => basename(f.CodeUri))); | |
config.entry = pick(config.entry, entryPoints); | |
// Alert if there is a function for which we are missing an entrypoint | |
const missing = difference(keys(config.entry), entryPoints); | |
if (missing.length > 0) throw new Error(`Could not find entrypoints ${missing.join(',')} in ${WEBPACK_CONFIG}`); | |
console.error(`Compiling entrypoint(s):\n${entryPoints.map(e => `- ${e}`).join('\n')}`); | |
// Setup compiler | |
// We add a hook to zip assets after compiling, so we upload them to lambda | |
// We cannot use compression-webpack-plugin because it doesn't support zip | |
// We cannot use zip-webpack-plugin because it doesn't support multiple entrypoints | |
// (see https://github.com/erikdesjardins/zip-webpack-plugin/issues/19) | |
const compiler = webpack(config); | |
compiler.hooks.assetEmitted.tapPromise('zip', async (filename) => { | |
if (extname(filename) === '.map') return; | |
const cwd = resolve(config.output.path, dirname(filename)); | |
await exec(`zip index.zip index.js`, { cwd }); | |
}); | |
// Compile! Webpack does not throw if there are compilation errors | |
// They need to be extracted from the stats object returned | |
const stats = await promisify(compiler.run.bind(compiler))(); | |
if (stats.hasErrors()) { | |
console.error(`Errors during compilation`); | |
console.error(stats.toJson().errors); | |
process.exit(1); | |
} else if (stats.hasWarnings()) { | |
console.error(`Warnings during compilation`); | |
console.error(stats.toJson().warnings); | |
} else { | |
console.error(`Compilation successful!\n`); | |
} | |
} | |
async function deployFunctions(functions) { | |
const lambda = new Lambda(); | |
const cfn = new CloudFormation(); | |
// We use describeStackResource to go from the logical name used in the stack (eg UserCreateFunction) | |
// to the actual name of the created resource (eg mystack-dev-user-create-fn) set in Properties.FunctionName | |
// We cannot directly use the value of Properties.FunctionName since it may be missing or may be a Fn::Sub | |
const resolvedFunctions = await Promise.all(functions.map(async (fn) => { | |
const response = await cfn.describeStackResource({ LogicalResourceId: fn.Name, StackName: STACKNAME }).promise(); | |
const resourceId = response.StackResourceDetail.PhysicalResourceId; | |
return { ...fn, ResourceId: resourceId }; | |
})); | |
// For each function, we upload the zip with the code generated by webpack | |
console.log(`Uploading functions:\n${resolvedFunctions.map(f => `- ${f.ResourceId}`).join('\n')}\n`); | |
await Promise.all(resolvedFunctions.map(async (fn) => { | |
const zipFilePath = resolve(fn.CodeUri, 'index.zip'); | |
await lambda.updateFunctionCode({ FunctionName: fn.ResourceId, ZipFile: readFileSync(zipFilePath) }).promise(); | |
})); | |
console.log(`Upload finished!`); | |
} | |
async function main() { | |
const fname = process.argv[2]; | |
if (!fname) throw new Error(`Provide a substring of the function names to deploy`); | |
if (!STACKNAME) throw new Error(`STACKNAME is missing from env`); | |
if (!process.env.AWS_PROFILE) throw new Error(`AWS_PROFILE is missing from env`); | |
if (!process.env.AWS_REGION) throw new Error(`AWS_REGION is missing from env`); | |
const functions = loadFunctions(fname); | |
if (functions.length === 0) return; | |
await compileFunctions(functions); | |
await deployFunctions(functions); | |
} | |
main().catch(err => { console.error('Error:', err.message); process.exit(1); }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment