UNPKG

@kumologica/builder

Version:
556 lines (486 loc) 19.6 kB
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;