@kumologica/builder
Version:
Kumologica build and deploy module
556 lines (486 loc) • 19.6 kB
JavaScript
const jsonata = require('jsonata');
const ecsTaskTrigger = require('./triggers/ecsTask');
class AWSCFECSFargateTemplate {
constructor() {}
/*
* Creates cloud formation template file for container running on ecs fargage:
* It is composed of:
* - task definition
* - task's role
* - container's role and policy
* - if task is defined then:
* - cloudwatch event
* - cloudwatch event role
* - if flow nodes are provided then
* - role added for each downstream aws node
*
* params:
* functionName
* zipFileName
* roleName
* deploymentBucketName
* description
* memory
* reservedConcurrency
* tracingConfig
* role-arn
* environment
* tags
* vpcConfig
* deadLetterConfig
*
* cpu
* image
*/
createCfTemplate(params, flow) {
let ts = new Date();
let template = {
AWSTemplateFormatVersion: '2010-09-09',
Resources: {},
Outputs: {}
// LambdaArn: {
// Description: 'The Arn of the kumologica flow lambda.',
// Value: { 'Fn::GetAtt': ['Lambda', 'Arn'] }
// }
// }
};
template.Resources.Task = {
TaskDefinition: {
Type: "AWS::ECS::TaskDefinition",
DependsOn: [
"LogGroup",
"TaskExecutionRole"
],
Properties: {
Family: params.functionName + "Task",
NetworkMode: "awsvpc",
RequiresCompatibilities: [ "FARGATE" ],
Cpu: params.cpu || 512,
Memory: params.memory || "1GB",
ExecutionRoleArn: { "Fn::GetAtt": ["TaskExecutionRole", "Arn"] },
TaskRoleArn: { "Fn::GetAtt": ["DockerRole", "Arn"] },
ContainerDefinitions: [
{
Name: params.functionName,
Image: params.image,
Essential: true,
Environment: [],
//"PortMappings": [
// {
// "ContainerPort": { "Ref": "ContainerPort" }
// }
//],
LogConfiguration: {
LogDriver: "awslogs",
Options: {
"awslogs-region": { "Ref": "AWS::Region" },
"awslogs-group": { "Ref": "LogGroup" },
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
}
}
// only assume role required
template.Resources.TaskExecutionRole = {
Type: "AWS::IAM::Role",
Properties: {
AssumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: {
Service: "ecs-tasks.amazonaws.com"
},
Action: "sts:AssumeRole"
}
]
},
ManagedPolicyArns: [
"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
]
}
}
let dockerRole = {
Type: 'AWS::IAM::Role',
Properties: {
RoleName: params.roleName,
AssumeRolePolicyDocument: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: {
Service: "ecs-tasks.amazonaws.com"
},
Action: ['sts:AssumeRole']
}
]
},
Policies: [
{
PolicyName: 'ECSDockerPolicy',
PolicyDocument: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents'
],
Resource: '*'
}
]
}
}
]
}
};
template.Resources.LogGroup = {
Type: "AWS::Logs::LogGroup",
Properties: {
LogGroupName: "/ecs/" + params.functionName,
RetentionInDays: params.logRetentionDays || 365
}
}
//if (params.description) {
// lambda.Properties.Description = params.description;
// }
if (params.environment) {
for (let key in params.tags) {
task.Properties.ContainerDefinitions.Environment.push(
{'Key': key, 'Value': params.environment[key]});
}
}
if (params.tags) {
for (let key in params.tags) {
task.Properties.Tags.push({'Key': key, 'Value': params.tags[key]});
}
}
// docker role
if (params["role-arn"]) {
task.Properties.TaskRoleArn = params["role-arn"];
}
// cw rule
//if (params.reservedConcurrency) {
// lambda.Properties.ReservedConcurrentExecutions = params.reservedConcurrency;
//}
// buffer for resources - action mapping to generate policy statements at the end
let resources = {};
if (params.triggers) {
template.Resources = Object.assign(template.Resources, this.handleTriggers(params, resources, flow));
}
if (!params["role-arn"]) {
template.Resources['DockerRole'] = dockerRole;
// adding all outbound services operations to iam role
if (flow) {
this.env = params.environment? params.environment.Variables: {};
let nodes = flow.filter(node => this.validNodeType(node));
//if (nodes !nodes.length) {
// console.log('nothing to process')
// return;
//}
for (let i=0; i<nodes.length; i++) {
switch(nodes[i].type) {
case 'Dynamo DB':
if (nodes[i].indexName && nodes[i].indexName.length > 0) {
let tableArn = this.mapValue(nodes[i].tableArn, nodes[i], params.strictMode);
let index = this.mapValue(nodes[i].indexName, nodes[i], params.strictMode);
this.addResourceAction(resources, `dynamodb:${nodes[i].operation}`, tableArn + "/index/" + index);
} else {
let tableArn = this.mapValue(nodes[i].tableArn, nodes[i], params.strictMode);
this.addResourceAction(resources, `dynamodb:${nodes[i].operation}`, tableArn);
}
// index
break;
case 'SQS':
let queueArn = this.mapQueueUrlToArn(this.mapValue(nodes[i].QueueUrl, nodes[i], params.strictMode));
this.addResourceAction(resources, `sqs:${nodes[i].operation}`, queueArn);
break;
case 'Lambda':
let lambdaArn = this.mapValue(nodes[i].LambdaArn, nodes[i], params.strictMode);
this.addResourceAction(resources, `lambda:InvokeFunction`, lambdaArn);
break;
case 'SNS':
let topic = "*";
if (nodes[i].PublishTopic && nodes[i].operation === "PublishTarget") {
topic = this.mapValue(nodes[i].PublishTopic, nodes[i], params.strictMode);
} else if (nodes[i].TargetARN && ["Push", "Publish"].includes(nodes[i].operation)) {
topic = this.mapValue(nodes[i].TargetARN, nodes[i], params.strictMode);
}
const operation = ["Push", "Publish", "PublishSMS", "PublishTarget"].includes(nodes[i].operation)? "Publish": nodes[i].operation;
this.addResourceAction(resources, `sns:${operation}`, topic);
break;
case 'SES':
this.addResourceAction(resources, `ses:${nodes[i].operation}`, '*');
break;
case 'SSM':
let key;
if (nodes[i].operation === "GetParametersByPath") {
key = this.mapValue(nodes[i].Path, nodes[i], params.strictMode);
} else {
key = this.mapValue(nodes[i].Key, nodes[i], params.strictMode);
}
if (key.startsWith('/')) {
key = key.substring(1);
}
const ssmArn = {'Fn::Sub': 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/' + key};
this.addResourceAction(resources, `ssm:${nodes[i].operation}`, ssmArn);
if (nodes[i].operation === "GetParameter" && nodes[i].Key.includes("/aws/reference/secretsmanager/")) {
const smArn = {'Fn::Sub': 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:*'};
this.addResourceAction(resources, "secretsmanager:GetSecretValue", smArn);
}
break;
case 'S3':
const bucketName = this.mapValue(nodes[i].Bucket, nodes[i], params.strictMode);
const bucketArn = `arn:aws:s3:::${bucketName}/*`;
this.addResourceAction(resources, `s3:${nodes[i].operation}`, bucketArn);
if (nodes[i].operation === "ListObjects") {
this.addResourceAction(resources, "s3:ListBucket", `arn:aws:s3:::${bucketName}`);
}
break;
case 'Cloudwatch':
let source = this.mapValue(nodes[i].Source, nodes[i], params.strictMode);
this.addResourceAction(resources, `event:${nodes[i].operation}`, source);
break;
case 'Rekognition':
const resource = this.mapRekognitionResource(nodes[i].operation, nodes[i]);
this.addResourceAction(resources, `rekognition:${nodes[i].operation}`, resource);
if (['DetectModerationLabels',
'DetectText',
'DetectLabels',
'DetectFaces',
'IndexFaces',
'RecognizeCelebrities',
'SearchFacesByImage',
'StartStreamProcessor',
'StopStreamProcessor'].includes(nodes[i].operation)) {
const bucketName = this.mapValue(nodes[i].Image, nodes[i], params.strictMode);
const bucketArn = `arn:aws:s3:::${bucketName}/*`;
this.addResourceAction(resources, `s3:Get*`, bucketArn);
this.addResourceAction(resources, `s3:List*`, bucketArn);
}
break;
default:
throw new Error(`Unsupported node type: ${node[i].type}, unable to generate IAM Policy.`);
}
}
let additionalPolicy;
if (params.policy) {
try {
additionalPolicy = params.policy;
if (!(additionalPolicy instanceof Array)) {
additionalPolicy = [additionalPolicy];
}
} catch(err) {
throw new Error(`Parameter policy: ${params.policy} is not in JSON format.`);
}
}
if (resources && Object.entries(resources).length > 0) {
let policy = this.createPolicy(resources);
if (additionalPolicy) {
policy.PolicyDocument.Statement.push(...additionalPolicy);
}
lambdaRole.Properties.Policies.push(policy);
} else {
if (additionalPolicy) {
let policy = this.createPolicy(null);
policy.PolicyDocument.Statement.push(...additionalPolicy);
lambdaRole.Properties.Policies.push(policy);
}
}
}
}
/*if (template.Resources.ApiGW) {
template.Outputs.RestApiId = {
Description: 'The id of rest api gateway created in this script.',
Value: { 'Fn::GetAtt': ['ApiGW', 'RestApiId'] }
}
template.Outputs.RootResourceId = {
Description: 'The root resource id of rest api gateway created in this script.',
Value: { 'Fn::GetAtt': ['ApiGW', 'RootResourceId'] }
}
}*/
return template;
}
// Method iterates through triggers and sets up relevant resources:
// event mappings or specific resource artefacts
handleTriggers(params, resourcesActions, flow) {
let resources = {};
for (let i=0; i<params.triggers.length; i++) {
switch(Object.keys(params.triggers[i])[0]) {
case 'ecsTask':
resources = Object.assign(resources, ecsTaskTrigger.trigger(params.triggers[i].task));
break;
case 'ecsApi':
//resources = Object.assign(resources, ecsTaskTrigger.trigger(params.triggers[i].task));
break;
}
}
return resources;
}
validNodeType(node) {
return node
&& node.type
&& ['Rekognition', 'S3', 'SQS', 'Cloudwatch', 'Dynamo DB', 'SNS', 'SES', 'SSM', 'Lambda'].includes(node.type);
}
/**
* Creates Kumologica policy based on passed resources array.
* Policy contains statements to allows specified actions
* for each resource in array.
*
* @param {array} resources
*/
createPolicy(resources) {
let policy = {
PolicyName: "KumologicaPolicy",
PolicyDocument: {
Version: "2012-10-17",
Statement: []
}
};
if (resources) {
policy.PolicyDocument.Statement =
Object.entries(resources)
.map(([resource, actions]) => (
{
Effect: "Allow",
Resource: [JSON.parse(resource)],
Action: actions
}
));
}
return policy;
}
mapQueueUrlToArn(queueUrl) {
const q = this.mapValue(queueUrl);
// https://sqs.region.amazonaws.com/account/queuename
let urlparts = q.split('/');
if (urlparts.length != 5) {
throw new Error(`Unable to handle queue url: ${q} expected format https://sqs.region.amazonaws.com/account/queuename`);
}
let region = urlparts[2].split('.')[1];
return `arn:aws:sqs:${region}:${urlparts[3]}:${urlparts[4]}`;
}
/**
* Function adds new action for specified resource or
* instantiates new resource with action if not yet provided
*
* @param {array} resources the buffer of resource actions (array)
* @param {String} action Specific action (f.e. s3:GetObject)
* @param {Object} resource Resource arn or formula (Sub, Ref ...)
*/
addResourceAction(resources, action, resource) {
const resourceString = JSON.stringify(resource);
const actions = resources[resourceString];
if (!actions) {
resources[resourceString] = [action];
} else {
if (!actions.includes(action)) {
actions.push(action);
}
}
}
/**
* This is dynamic expression evaluation function that uses jsonata.
* It is simplified version of evaluateDynamicField function from runtime util.js.
* The version below only supports environment variables in expressions.
*
* In strict mode if value contains references to msg. or vars. error is returned.
* Non strict mode replaces such a value with '*' and displays warning.
*
* If any error occurs during evaluation or evaluation returns no value
* the original value (key) is returned
*
* @param {String} value - either literal string or jsonata expression with env reference
*/
mapValue(value, node, strictMode) {
// data contains all objects for sourcing data for expression:
// in plugin case only env are supported
var data = {};
data.env = this.env;
// verify that expression has no msg. and vars. references nor is empty
// in strict mode. If so reject the export
if (!value || value === "" || value.includes('msg.') || value.includes('vars.')) {
if (strictMode) {
throw new Error(`Resource reference contains unknown parameters, unable to determine value. Node: id=${node.id} name: ${node.name} type: ${node.type} value: ${value}`);
} else {
// display warning
console.warn(`Warning: Resource reference contains unknown parameters, strict mode disabled, replacing resource reference with "*", node: id=${node.id} name: ${node.name} type: ${node.type}`);
return '*';
}
}
let response;
try {
let expression = jsonata(value);
response = expression.evaluate(data);
if (!response) {
response = value;
}
} catch (Error) {
response = value;
}
return this.sanitizeValue(response);
}
/**
* docs: https://docs.aws.amazon.com/rekognition/latest/dg/api-permissions-reference.html
*
* @param {*} operation
* @param {*} node
*/
mapRekognitionResource(operation, node) {
switch (operation) {
case 'CreateCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId;
case 'DeleteCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId;
case 'DeleteFaces': return 'arn:aws:rekognition:$${AWS::Region}:${AWS::AccountId}:collection/$' + node.CollectionId;
case 'DescribeCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId;
case 'IndexFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId;
case 'ListCollections': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:*';
case 'ListFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId;
case 'SearchFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId;
case 'SearchFacesByImage': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId;
case 'CreateStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:*';
//case 'CreateStreamProcessor': return `arn:aws:rekognition:${region}:${accountId}:collection/collection-id`;
//`arn:aws:rekognition:${region}:${accountId}:streamprocessor/stream-processor-name`
case 'DeleteStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name;
case 'ListStreamProcessors': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name;
case 'StartStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name;
case 'StopStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name;
case 'CompareFaces': return '*';
case 'DetectFaces': return '*';
case 'DetectLabels': return '*';
case 'DetectModerationLabels': return '*';
case 'DetectText': return '*';
case 'GetCelebrityInfo': return '*';
case 'RecognizeCelebrities': return '*';
case 'GetCelebrityRecognition': return '*';
case 'GetContentModeration': return '*';
case 'GetFaceDetection': return '*';
case 'GetLabelDetection': return '*';
case 'GetPersonTracking': return '*';
case 'StartCelebrityRecognition': return '*';
case 'StartContentModeration': return '*';
case 'StartFaceDetection': return '*';
case 'StartFaceSearch': return '*';
case 'StartLabelDetection': return '*';
case 'StartPersonTracking': return "*";
default: return "*";
}
}
// Removes strings in quotes, they will cause cloudformation script to be invalid
sanitizeValue(value) {
if (value && value.trim) {
let v = value.trim();
if (v.charAt(0) === '"' && v.charAt(v.length -1) === '"') {
return v.substr(1,v.length -2);
}
}
return value;
}
}
module.exports = AWSCFECSFargateTemplate;