samtsc
Version:
This project was put together to make working with the AWS SAM framework easier for developers. It simplifies working with the SAM framework, using real-time updates to lambda functions, layers and resources. This is done by **samtsc** connecting to the
561 lines (511 loc) • 23.9 kB
JavaScript
const yaml = require('js-yaml');
const { existsSync, writeFileSync, readFileSync, unlinkSync, mkdir } = require('../file-system');
const { relative, resolve } = require('path');
const cfSchema = require('cloudformation-js-yaml-schema');
const aws = require('@aws-sdk/client-ssm');
const awscf = require('@aws-sdk/client-cloudformation')
const { logger } = require('../logger');
const { EventEmitter } = require('events');
const { SAMLayer } = require('./layer');
const { SAMFunction } = require('./function');
const { SAMCompiledDirectory } = require('./compiled-directory');
const { AppSyncFunctionConfiguration } = require('./appsync-function');
const { writeCacheFile, folderUpdated } = require('../tsc-tools');
const { AppSyncSchema } = require('./appsync-schema');
const { version } = require('os');
function filterRefs(array) {
if(!array) {
return [];
}
return array.filter(x => x.name == 'Ref').map(x => x.data).filter(x => x? true : false);
}
class SAMTemplate {
constructor(path, buildRoot, samconfig, stackName, isSubstack) {
this.samconfig = samconfig;
this.stackName = stackName;
this.path = path;
this.buildRoot = buildRoot;
const lastForwardSlash = path.lastIndexOf('/');
this.stackDir = lastForwardSlash > 0? path.slice(0, lastForwardSlash) : ".";
this.events = new EventEmitter();
this.ssm = new aws.SSM({ region: samconfig.region });
this.cf = new awscf.CloudFormation({ region: samconfig.region });
}
cleanup() {
this.compiledDirectories && Object.values(this.compiledDirectories).forEach(x => x.cleanup());
this.layers && this.layers.forEach(l => l.cleanup());
this.functions && this.functions.forEach(f => f.cleanup());
}
fileEvent(filePath) {
var ignoreReload = false;
logger.debug('File event', filePath);
if(!filePath) {
return;
}
if(this.path == filePath && folderUpdated(this.path)) {
logger.info('Template file changed', this.path);
this.reload();
writeCacheFile(this.path, true);
}
if(this.substacks) {
this.substacks.forEach(x => x.fileEvent(filePath));
}
if(this.compiledDirectories) {
Object.values(this.compiledDirectories).forEach(d => {
const subpath = relative(d.path, filePath);
if(!subpath.startsWith('..')) {
logger.debug('Compiled Directory Changed', subpath);
d.fileEvent(subpath);
}
});
}
if(this.appsyncFunctions && !filePath.startsWith(this.buildRoot)) {
const fullPath = resolve(filePath);
this.appsyncFunctions.forEach(f => {
logger.debug('Appsync Function Check', f.source, fullPath);
if(f.source == fullPath) {
logger.debug('Appsync Function Changed', filePath);
f.fileEvent(filePath);
ignoreReload = true;
}
});
}
if(this.appsyncSchemas) {
const fullPath = resolve(filePath);
this.appsyncSchemas.forEach(f => {
if(f.paths.find(p => p == fullPath)) {
logger.debug('Appsync Schema Changed', filePath);
f.fileEvent(filePath);
ignoreReload = true;
}
});
}
if(this.layers) {
this.layers.forEach(l => {
const subpath = relative(l.path, filePath);
if(!subpath.startsWith('..')) {
l.fileEvent(subpath);
}
if(l.libs) {
l.libs.forEach(d => {
const subpath = relative(d.path, filePath);
if(!subpath.startsWith('..')) {
d.fileEvent(subpath);
}
});
}
});
}
return ignoreReload;
}
async reload() {
console.log('samtsc: Loading Template', this.path);
if(!this.isSubstack && !existsSync('samconfig.toml')) {
throw new Error('No samconfig.toml found for default deployment configurations');
}
this.samconfig.save();
let nextToken;
const resources = [];
if(this.stackName && !this.samconfig.package) {
try {
do {
const result = await this.cf.listStackResources({
StackName: this.stackName,
NextToken: nextToken
});
nextToken = result.NextToken;
if(result.StackResourceSummaries) {
resources.push(...result.StackResourceSummaries);
}
} while(nextToken);
} catch (err) {
logger.error(err);
}
}
const content = readFileSync(this.path).toString();
console.log('File read');
const template = yaml.load(content, {
schema: cfSchema.CLOUDFORMATION_SCHEMA
});
this.parameters = template.Parameters;
let globalUri;
if(template.Globals && template.Globals.Function && template.Globals.Function.CodeUri) {
globalUri = template.Globals.Function.CodeUri;
}
const self = this;
if(!this.compiledDirectories) {
this.compiledDirectories = {};
}
const appsyncFunctionConfigKeys = Object.keys(template.Resources)
.filter(key => template.Resources[key].Type == 'AWS::AppSync::FunctionConfiguration');
if(!this.appsyncFunctions) {
this.appsyncFunctions = [];
}
const appsync = resources.find(resource => resource.ResourceType == 'AWS::AppSync::GraphQLApi');
let appsyncId = '';
if(appsync) {
appsyncId = appsync.PhysicalResourceId.slice(appsync.PhysicalResourceId.lastIndexOf('/') + 1);
logger.debug('appsync', appsyncId);
}
appsyncFunctionConfigKeys.forEach(key => {
const resource = template.Resources[key];
let existing = this.appsyncFunctions.find(x => x.name == key);
const cfresource = resources.find(x => x.LogicalResourceId == key);
if(existing) {
existing.setConfig(resource.Properties, cfresource, appsyncId);
} else {
existing = new AppSyncFunctionConfiguration(key, resource.Properties, self.buildRoot, cfresource, appsyncId, this.samconfig);
this.appsyncFunctions.push(existing);
}
if(existing.source?.endsWith('.ts')) {
const dirPath = relative('.', resolve(this.stackDir, existing.source, '..'));
let compDir = this.compiledDirectories[dirPath];
if(!compDir) {
logger.info('Constructing directory to compile', dirPath);
compDir = new SAMCompiledDirectory(dirPath, this.samconfig, this.buildRoot, '.build/tmp', 'ESNext');
compDir.neverPackage = true;
compDir.installAtLeastOnce();
compDir.build(undefined, true);
this.compiledDirectories[dirPath] = compDir;
}
existing.setCompDir(compDir);
}
});
const appsyncSchemaKeys = Object.keys(template.Resources)
.filter(key => template.Resources[key].Type == 'AWS::AppSync::GraphQLSchema');
if(!this.appsyncSchemas) {
this.appsyncSchemas = [];
}
appsyncSchemaKeys.forEach(key => {
const resource = template.Resources[key];
const existing = this.appsyncFunctions.find(x => x.name == key);
const cfresource = resources.find(x => x.LogicalResourceId == key);
if(existing) {
existing.setConfig(resource.Properties, cfresource, appsyncId);
} else {
const appsyncFunc = new AppSyncSchema(key, resource.Properties, self.buildRoot, cfresource, appsyncId, this.samconfig);
this.appsyncSchemas.push(appsyncFunc);
}
});
const layerKeys = Object.keys(template.Resources)
.filter(key => template.Resources[key].Type == 'AWS::Serverless::LayerVersion');
if(!this.layers) {
this.layers = [];
}
layerKeys
.forEach(key => {
const resource = template.Resources[key];
const existing = this.layers.find(x => x.name == key);
if(existing) {
existing.setConfig(resource.Properties, resource.Metadata);
} else {
const layer = new SAMLayer(key, resource.Properties, resource.Metadata, this.samconfig.stack_name, self.buildRoot, this.samconfig);
layer.events.on('layer-change', () => self.events.emit('layer-change'));
this.layers.push(layer);
}
});
this.layers = this.layers.filter(x => {
if(layerKeys.find(y => y == x.name)) {
return true;
}
x.cleanup();
return false;
});
const applicationKeys = Object.keys(template.Resources)
.filter(key => template.Resources[key].Type == 'AWS::Serverless::Application');
if(applicationKeys && applicationKeys.length > 0) {
const subStackRef = [];
this.substacks = applicationKeys.map(key => {
const resource = resources.find(x => x.LogicalResourceId == key);
let substackName = '';
if(resource) {
substackName = resource.PhysicalResourceId;
}
const location = template.Resources[key].Properties.Location;
const retval = new SAMTemplate(location, this.buildRoot, this.samconfig, substackName, true);
retval.events.on('template-update', () => {
self.events.emit('template-update', self);
});
subStackRef.push({
declaration: template.Resources[key],
template: retval
});
return retval;
});
await Promise.all(this.substacks.map(async x => {
await x.reload();
logger.debug('Substack params', x);
if(x.parameters) {
Object.keys(x.parameters).forEach(key => {
const p = x.parameters[key];
logger.debug(JSON.stringify(p));
if(!p.Default && p.Type && p.Type.indexOf('AWS::SSM::Parameter::Value') != 0) {
return;
}
// Check if parent stack has this parameter passed in
const stack = subStackRef.find(y => y.template == x);
const subParams = stack &&
stack.declaration &&
stack.declaration.Properties &&
stack.declaration.Properties.Parameters?
stack.declaration.Properties.Parameters : undefined;
if(subParams && subParams[key]) {
return;
}
// Add parameters to parent stack
if(!this.parameters[`SubStack${key}`]) {
this.parameters[`SubStack${key}`] = {
Type: 'String',
Default: p.Default
};
}
subParams[key] = { Ref: `SubStack${key}`};
});
}
}));
}
if(template.Parameters && !this.isSubstack) {
logger.warn('environment aware turned on');
const environments = this.samconfig.package && this.samconfig.environments? this.samconfig.environments.split(',') : [this.samconfig.environment];
this.templateConfigurations = environments.map(env => {
const retval = {
Parameters: {},
Tags: {
"environment": env
}
};
const envAware = this.samconfig.env_aware == 'true' || this.samconfig.env_aware == true;
logger.debug('envAware', envAware);
Object.keys(template.Parameters)
.forEach(key => {
if(!template.Parameters[key].Default) {
return;
}
if(key == 'EnvironmentTagName') {
retval.Parameters[key] = env;
} else {
retval.Parameters[key] = template.Parameters[key].Default.toString();
}
if(envAware) {
retval.Parameters[key] = retval.Parameters[key]
.replace(/\<EnvironmentName\>/g, env)
.replace(/\<DevStack\>/g, this.samconfig.dev_stack? this.samconfig.dev_stack : '');
}
});
logger.debug('Writing config for', env);
writeFileSync(resolve(this.buildRoot, `template-${env}.config`), JSON.stringify(retval, undefined, ' '));
return retval;
});
logger.debug('templateConfigurations', this.templateConfigurations);
}
if(!this.functions) {
this.functions = [];
}
const functionKeys = Object.keys(template.Resources)
.filter(key => template.Resources[key].Type == 'AWS::Serverless::Function' && !template.Resources[key].Properties.InlineCode);
functionKeys.forEach(key => {
const resource = template.Resources[key];
let samFunc = this.functions.find(x => x.name == key);
if(samFunc) {
samFunc.setConfig(resource.Properties, globalUri);
} else {
samFunc = new SAMFunction(key, resource.Properties, globalUri, self.samconfig, this.stackName, resources);
this.functions.push(samFunc);
}
let compDir = this.compiledDirectories[samFunc.path];
if(!compDir) {
console.log('samtsc: Constructing directory to compile', samFunc.path);
const dirPath = relative('.', resolve(this.stackDir, samFunc.path));
compDir = new SAMCompiledDirectory(dirPath, this.samconfig, this.buildRoot);
compDir.installAtLeastOnce();
compDir.build(undefined, true);
this.compiledDirectories[samFunc.path] = compDir;
}
samFunc.registerCompiledDirectory(compDir);
});
this.functions = this.functions.filter(x => {
const result = functionKeys.find(y => y == x.name);
if(result) {
return true;
}
x.cleanup();
return false;
});
Object.values(this.compiledDirectories).forEach(x => {
if(!this.functions.find(y => y.path == x.path)) {
x.cleanup();
}
});
if(this.samconfig.parm_layer == 'true') {
const layerRefs = [];
if(template.Globals && template.Globals.Function && template.Globals.Function.Layers) {
console.log('samtsc: Checking global function values');
layerRefs.push(...filterRefs(template.Globals.Function.Layers));
}
this.functions.forEach(f => {
layerRefs.push(...filterRefs(f.layers));
});
const paramNames = Object.keys(template.Parameters || {});
const paramRefs = layerRefs.filter(r => paramNames.find(x => x == r) && template.Parameters[r].Type == 'AWS::SSM::Parameter::Value<String>');
if(paramRefs.length > 0) {
const paramResults = await this.ssm.getParameters({
Names: paramRefs.map(p => template.Parameters[p].Default)
});
paramRefs.forEach(key => {
const parm = template.Parameters[key];
const paramValue = paramResults.Parameters.find(x => x.Name == parm.Default);
if(paramValue) {
parm.Default = paramValue.Value;
parm.Type = 'String';
}
});
}
}
const buildPath = `${this.buildRoot}/${this.path}`;
const absPath = resolve(buildPath);
if(existsSync(absPath)) {
logger.debug('File exists', absPath);
unlinkSync(absPath);
}
const folder = relative(buildPath, '..');
if(!existsSync(folder)) {
mkdir(folder);
}
if(this.samconfig.marker_tag) {
const resourceName = `DeploymentMarkerTag${this.samconfig.marker_tag}`;
Object.values(template.Resources).forEach(r => {
if(!r.DependsOn) {
r.DependsOn = resourceName;
} else if(typeof r.DependsOn == 'string') {
r.DependsOn = [r.DependsOn, resourceName];
} else if(Array.isArray(r.DependsOn)) {
r.DependsOn.push(resourceName);
}
});
template.Resources[resourceName] = {
Type: 'AWS::CloudFormation::WaitConditionHandle'
};
if(!template.Outputs) {
template.Outputs = {};
}
template.Outputs['DeploymentHistoryTag'] = {
Description: 'Stackery Deployment History Tag',
Value: this.samconfig.marker_tag
};
}
if(this.samconfig.base_stack && template.Parameters && template.Parameters.StackTagName) {
template.Parameters.StackTagName.Default = this.samconfig.base_stack;
}
if(this.samconfig.base_stack && template.Parameters && template.Parameters.StackName) {
template.Parameters.StackName.Default = this.samconfig.base_stack;
}
if(this.samconfig.environment && template.Parameters && template.Parameters.EnvironmentTagName) {
template.Parameters.EnvironmentTagName.Default = this.samconfig.environment;
}
if(this.samconfig.environment && template.Parameters && template.Parameters.EnvironmentName) {
template.Parameters.EnvironmentName.Default = this.samconfig.environment;
}
if(this.samconfig.dev_stack && template.Parameters && template.Parameters.DevStackName) {
template.Parameters.DevStackName.Default = this.samconfig.dev_stack;
}
logger.info('Writing file', buildPath)
this.fixGlobalApiPermissions(template);
this.mergeGlobalPolicies(template);
let templateString = yaml.dump(template, { schema: cfSchema.CLOUDFORMATION_SCHEMA})
const versionMatches = templateString.match(/\:\W*\d+\.\d+\.\d+\W*(?<=\n)/g);
if(versionMatches) {
logger.warn("Replacing non-string version numbers with strings");
versionMatches.forEach(m => {
const val = m.match(/\d+\.\d+\.\d+/)[0];
const updated = m.replace(val, `"${val}"`);
templateString = templateString.replace(m, updated);
});
}
writeFileSync(buildPath, templateString);
this.events.emit('template-update', this);
}
/**
* This function is used to fix permissions for global api references.
* There is an issue in the sam framework where global apis are not given
* invoke permissions on lambda functions.
* @param {map} template
*/
fixGlobalApiPermissions(template) {
Object.keys(template.Resources)
.filter(x => {
const f = template.Resources[x];
if(f.Type != 'AWS::Serverless::Function' || !f.Properties || !f.Properties.Events) {
return;
}
if(!Object.values(f.Properties.Events).find(y => {
return y.Type == 'Api' && y.Properties;
})) {
return;
}
if(Object.values(template.Resources).find(y => {
if(y.Type != 'AWS::Lambda::Permission' || !y.Properties) {
return;
}
if(y.Properties.Action != 'lambda:InvokeFunction' ||
!y.Properties.FunctionName ||
!(y.Properties.FunctionName.Ref == x || y.Properties.FunctionName.data == x)) {
return;
}
return true;
})) {
return;
}
return true;
}).forEach(x => {
let apiResource = 'ServerlessRestApi';
const f = template.Resources[x];
const ev = Object.values(f.Properties.Events).find(y => {
return y.Type == 'Api' && y.Properties && y.Properties.RestApiId;
});
if(ev) {
apiResource = ev.Properties.RestApiId.Ref || ev.Properties.RestApiId.data || apiResource;
}
const permissions = {
Type: 'AWS::Lambda::Permission',
Properties: {
Action: 'lambda:InvokeFunction',
FunctionName: { Ref: x },
Principal: 'apigateway.amazonaws.com',
SourceArn: { 'Fn::Sub': "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${" + apiResource + "}/*/*/*" }
}
};
template.Resources[x + 'Permissions' + new Date().getTime().toString()] = permissions;
});
}
mergeGlobalPolicies(template) {
if(!(template.Globals && template.Globals.Function && template.Globals.Function.Policies)) {
return;
}
const globalPolicies = template.Globals.Function.Policies.find(x => !x.Statement);
if(!Array.isArray(template.Globals.Function.Policies)) {
logger.error('Globals.Function.Policies is not an array', globalPolicies);
throw new Error('Globals.Function.Policies is not an array');
}
const statement = template.Globals.Function.Policies.find(x => x.Statement);
Object.values(template.Resources)
.filter(f => f.Type == 'AWS::Serverless::Function' && f.Properties)
.forEach(f => {
if(!f.Properties.Policies) {
f.Properties.Policies = [];
}
if(statement) {
const curStatement = f.Properties.Policies.find(x => x.Statement);
if(!curStatement) {
f.Properties.Policies.push(statement);
} else {
curStatement.Statement.push(...statement.Statement);
}
}
if(globalPolicies) {
f.Properties.Policies.push(globalPolicies);
}
});
delete template.Globals.Function.Policies;
}
}
module.exports.SAMTemplate = SAMTemplate;