UNPKG

@aws-amplify/amplify-category-api

Version:
583 lines (495 loc) 18.8 kB
import * as path from 'path'; import * as fs from 'fs-extra'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecr from 'aws-cdk-lib/aws-ecr'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as iam from 'aws-cdk-lib/aws-iam'; import { CfnFunction } from 'aws-cdk-lib/aws-lambda'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as ssm from 'aws-cdk-lib/aws-secretsmanager'; import * as cloudmap from 'aws-cdk-lib/aws-servicediscovery'; import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { NETWORK_STACK_LOGICAL_ID } from '../../category-constants'; import Container from './docker-compose/ecs-objects/container'; import { GitHubSourceActionInfo, PipelineWithAwaiter } from './pipeline-with-awaiter'; const PIPELINE_AWAITER_ZIP = 'custom-resource-pipeline-awaiter-18.zip'; export enum DEPLOYMENT_MECHANISM { /** * on every amplify push. */ FULLY_MANAGED = 'FULLY_MANAGED', /** * on every github push */ INDENPENDENTLY_MANAGED = 'INDENPENDENTLY_MANAGED', /** * manually push by the customer to ECR */ SELF_MANAGED = 'SELF_MANAGED', } export type ContainersStackProps = Readonly<{ skipWait?: boolean; categoryName: string; apiName: string; dependsOn: ReadonlyArray<{ category: string; resourceName: string; attributes: string[]; }>; taskEnvironmentVariables?: Record<string, any>; deploymentMechanism: DEPLOYMENT_MECHANISM; restrictAccess: boolean; policies?: ReadonlyArray<iam.PolicyStatement | Record<string, any>>; containers: ReadonlyArray<Container>; secretsArns?: ReadonlyMap<string, string>; exposedContainer: { name: string; port: number }; taskPorts: number[]; isInitialDeploy: boolean; desiredCount: number; createCloudMapService?: boolean; gitHubSourceActionInfo?: GitHubSourceActionInfo; existingEcrRepositories: Set<string>; currentStackName: string; }>; export abstract class ContainersStack extends cdk.Stack { protected readonly vpcId: string; private readonly vpcCidrBlock: string; protected readonly subnets: ReadonlyArray<string>; private readonly clusterName: string; private readonly zipPath: string; private readonly cloudMapNamespaceId: string; protected readonly vpcLinkId: string; private readonly pipelineWithAwaiter: PipelineWithAwaiter; protected readonly cloudMapService: cloudmap.CfnService | undefined; protected readonly ecsService: ecs.CfnService; protected readonly isAuthCondition: cdk.CfnCondition; protected readonly appClientId: string | undefined; protected readonly userPoolId: string | undefined; protected readonly ecsServiceSecurityGroup: ec2.CfnSecurityGroup; protected readonly parameters: ReadonlyMap<string, cdk.CfnParameter>; protected readonly envName: string; protected readonly deploymentBucketName: string; protected readonly awaiterS3Key: string; constructor(scope: Construct, id: string, private readonly props: ContainersStackProps) { super(scope, id, { synthesizer: new cdk.LegacyStackSynthesizer() }); const { parameters, vpcId, vpcCidrBlock, subnets, clusterName, zipPath, cloudMapNamespaceId, vpcLinkId, isAuthCondition, appClientId, userPoolId, envName, deploymentBucketName, awaiterS3Key, } = this.init(); this.parameters = parameters; this.vpcId = vpcId; this.vpcCidrBlock = vpcCidrBlock; this.subnets = subnets; this.clusterName = clusterName; this.zipPath = zipPath; this.cloudMapNamespaceId = cloudMapNamespaceId; this.vpcLinkId = vpcLinkId; this.isAuthCondition = isAuthCondition; this.appClientId = appClientId; this.userPoolId = userPoolId; this.envName = envName; this.deploymentBucketName = deploymentBucketName; this.awaiterS3Key = awaiterS3Key; const { service, serviceSecurityGroup, containersInfo, cloudMapService } = this.ecs(); this.cloudMapService = cloudMapService; this.ecsService = service; this.ecsServiceSecurityGroup = serviceSecurityGroup; const { gitHubSourceActionInfo, skipWait } = this.props; const { pipelineWithAwaiter } = this.pipeline({ skipWait, service, containersInfo: containersInfo.filter((container) => container.repository), gitHubSourceActionInfo, }); this.pipelineWithAwaiter = pipelineWithAwaiter; new cdk.CfnOutput(this, 'ContainerNames', { value: cdk.Fn.join( ',', containersInfo.map(({ container: { containerName } }) => containerName), ), }); } private init() { const { restrictAccess, dependsOn, deploymentMechanism } = this.props; // Unused in this stack, but required by the root stack new cdk.CfnParameter(this, 'env', { type: 'String' }); const paramDomain = new cdk.CfnParameter(this, 'domain', { type: 'String', default: '' }); const paramRestrictAccess = new cdk.CfnParameter(this, 'restrictAccess', { type: 'String', allowedValues: ['true', 'false'], default: 'false', }); const paramZipPath = new cdk.CfnParameter(this, 'ParamZipPath', { type: 'String', // Required only for FULLY_MANAGED default: deploymentMechanism === DEPLOYMENT_MECHANISM.FULLY_MANAGED ? undefined : '', }); const parameters: Map<string, cdk.CfnParameter> = new Map(); parameters.set('ParamZipPath', paramZipPath); parameters.set('domain', paramDomain); parameters.set('restrictAccess', paramRestrictAccess); const authParams: { UserPoolId?: cdk.CfnParameter; AppClientIDWeb?: cdk.CfnParameter; } = {}; const paramTypes: Record<string, string> = { NetworkStackSubnetIds: 'CommaDelimitedList', }; dependsOn.forEach(({ category, resourceName, attributes }) => { attributes.forEach((attrib) => { const paramName = [category, resourceName, attrib].join(''); const type = paramTypes[paramName] ?? 'String'; const param = new cdk.CfnParameter(this, paramName, { type }); parameters.set(paramName, param); if (category === 'auth') { authParams[attrib as keyof typeof authParams] = param; } }); }); const paramVpcId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcId`); const paramVpcCidrBlock = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcCidrBlock`); const paramSubnetIds = parameters.get(`${NETWORK_STACK_LOGICAL_ID}SubnetIds`); const paramClusterName = parameters.get(`${NETWORK_STACK_LOGICAL_ID}ClusterName`); const paramCloudMapNamespaceId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}CloudMapNamespaceId`); const paramVpcLinkId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcLinkId`); const { UserPoolId: paramUserPoolId, AppClientIDWeb: paramAppClientIdWeb } = authParams; const isAuthCondition = new cdk.CfnCondition(this, 'isAuthCondition', { expression: cdk.Fn.conditionAnd( cdk.Fn.conditionEquals(restrictAccess, true), cdk.Fn.conditionNot(cdk.Fn.conditionEquals(paramUserPoolId ?? '', '')), cdk.Fn.conditionNot(cdk.Fn.conditionEquals(paramAppClientIdWeb ?? '', '')), ), }); const stackNameParameter = new cdk.CfnParameter(this, 'rootStackName', { type: 'String', }); const deploymentBucketName = new cdk.CfnParameter(this, 'deploymentBucketName', { type: 'String', }); const awaiterS3Key = new cdk.CfnParameter(this, 'awaiterS3Key', { type: 'String', default: PIPELINE_AWAITER_ZIP, }); return { parameters, vpcId: paramVpcId.valueAsString, vpcCidrBlock: paramVpcCidrBlock.valueAsString, subnets: paramSubnetIds.valueAsList, clusterName: paramClusterName.valueAsString, zipPath: paramZipPath.valueAsString, cloudMapNamespaceId: paramCloudMapNamespaceId.valueAsString, vpcLinkId: paramVpcLinkId.valueAsString, isAuthCondition, userPoolId: paramUserPoolId && paramUserPoolId.valueAsString, appClientId: paramAppClientIdWeb && paramAppClientIdWeb.valueAsString, envName: stackNameParameter.valueAsString, deploymentBucketName: deploymentBucketName.valueAsString, awaiterS3Key: awaiterS3Key.valueAsString, }; } private ecs() { const { categoryName, apiName, policies, containers, secretsArns, taskEnvironmentVariables, exposedContainer, taskPorts, isInitialDeploy, desiredCount, currentStackName, createCloudMapService, } = this.props; let cloudMapService: cloudmap.CfnService = undefined; if (createCloudMapService) { cloudMapService = new cloudmap.CfnService(this, 'CloudmapService', { name: apiName, dnsConfig: { dnsRecords: [ { ttl: 60, type: cloudmap.DnsRecordType.SRV, }, ], namespaceId: this.cloudMapNamespaceId, routingPolicy: cloudmap.RoutingPolicy.MULTIVALUE, }, }); } const task = new ecs.TaskDefinition(this, 'TaskDefinition', { compatibility: ecs.Compatibility.FARGATE, memoryMiB: '1024', cpu: '512', family: `${this.envName}-${apiName}`, }); (task.node.defaultChild as ecs.CfnTaskDefinition).overrideLogicalId('TaskDefinition'); policies.forEach((policy) => { const statement = isPolicyStatement(policy) ? policy : jsonPolicyToCdkPolicyStatement(policy); task.addToTaskRolePolicy(statement); }); const containersInfo: { container: ecs.ContainerDefinition; repository: ecr.IRepository; }[] = []; containers.forEach( ({ name, image, build, portMappings, logConfiguration, environment, entrypoint: entryPoint, command, working_dir: workingDirectory, healthcheck: healthCheck, secrets: containerSecrets, }) => { const logGroup = new logs.LogGroup(this, `${name}ContainerLogGroup`, { logGroupName: `/ecs/${this.envName}-${apiName}-${name}`, retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const { logDriver, options: { 'awslogs-stream-prefix': streamPrefix } = {} } = logConfiguration; const logging: ecs.LogDriver = logDriver === 'awslogs' ? ecs.LogDriver.awsLogs({ streamPrefix, logGroup: logs.LogGroup.fromLogGroupName(this, `${name}logGroup`, logGroup.logGroupName), }) : undefined; let repository: ecr.IRepository; if (build) { const logicalId = `${name}Repository`; const repositoryName = `${currentStackName}-${categoryName}-${apiName}-${name}`; if (this.props.existingEcrRepositories.has(repositoryName)) { repository = ecr.Repository.fromRepositoryName(this, logicalId, repositoryName); } else { repository = new ecr.Repository(this, logicalId, { repositoryName: `${this.envName}-${categoryName}-${apiName}-${name}`, removalPolicy: cdk.RemovalPolicy.RETAIN, lifecycleRules: [ { rulePriority: 10, maxImageCount: 1, tagPrefixList: ['latest'], tagStatus: ecr.TagStatus.TAGGED, }, { rulePriority: 100, maxImageAge: cdk.Duration.days(7), tagStatus: ecr.TagStatus.ANY, }, ], }); (repository.node.defaultChild as ecr.CfnRepository).overrideLogicalId(logicalId); } // Needed because the image will be pulled from ecr repository later repository.grantPull(task.obtainExecutionRole()); } const secrets: ecs.ContainerDefinitionOptions['secrets'] = {}; const environmentWithoutSecrets = environment || {}; containerSecrets.forEach((s, i) => { if (secretsArns.has(s)) { secrets[s] = ecs.Secret.fromSecretsManager(ssm.Secret.fromSecretPartialArn(this, `${name}secret${i + 1}`, secretsArns.get(s))); } delete environmentWithoutSecrets[s]; }); const container = task.addContainer(name, { image: repository ? ecs.ContainerImage.fromEcrRepository(repository) : ecs.ContainerImage.fromRegistry(image), logging, environment: { ...taskEnvironmentVariables, ...environmentWithoutSecrets, }, entryPoint, command, workingDirectory, healthCheck: healthCheck && { command: healthCheck.command, interval: cdk.Duration.seconds(healthCheck.interval ?? 30), retries: healthCheck.retries, timeout: cdk.Duration.seconds(healthCheck.timeout ?? 5), startPeriod: cdk.Duration.seconds(healthCheck.start_period ?? 0), }, secrets, }); containersInfo.push({ container, repository, }); // TODO: should we use hostPort too? check network mode portMappings?.forEach(({ containerPort, protocol, hostPort }) => { container.addPortMappings({ containerPort, protocol: ecs.Protocol.TCP, }); }); }, ); const serviceSecurityGroup = new ec2.CfnSecurityGroup(this, 'ServiceSG', { vpcId: this.vpcId, groupDescription: 'Service SecurityGroup', securityGroupEgress: [ { description: 'Allow all outbound traffic by default', cidrIp: '0.0.0.0/0', ipProtocol: '-1', }, ], securityGroupIngress: taskPorts.map((servicePort) => ({ ipProtocol: 'tcp', fromPort: servicePort, toPort: servicePort, cidrIp: this.vpcCidrBlock, })), }); let serviceRegistries: ecs.CfnService.ServiceRegistryProperty[] = undefined; if (cloudMapService) { serviceRegistries = [ { containerName: exposedContainer.name, containerPort: exposedContainer.port, registryArn: cloudMapService.attrArn, }, ]; } const service = new ecs.CfnService(this, 'Service', { serviceName: `${apiName}-service-${exposedContainer.name}-${exposedContainer.port}`, cluster: this.clusterName, launchType: 'FARGATE', desiredCount: isInitialDeploy ? 0 : desiredCount, // This is later adjusted by the Predeploy action in the codepipeline networkConfiguration: { awsvpcConfiguration: { assignPublicIp: 'ENABLED', securityGroups: [serviceSecurityGroup.attrGroupId], subnets: <string[]>this.subnets, }, }, taskDefinition: task.taskDefinitionArn, serviceRegistries, }); new cdk.CfnOutput(this, 'ServiceName', { value: service.serviceName, }); new cdk.CfnOutput(this, 'ClusterName', { value: this.clusterName, }); return { service, serviceSecurityGroup, containersInfo, cloudMapService, }; } private pipeline({ skipWait = false, service, containersInfo, gitHubSourceActionInfo, }: { skipWait?: boolean; service: ecs.CfnService; containersInfo: { container: ecs.ContainerDefinition; repository: ecr.IRepository; }[]; gitHubSourceActionInfo?: GitHubSourceActionInfo; }) { const { deploymentMechanism, desiredCount } = this.props; const s3SourceActionKey = this.zipPath; const bucket = s3.Bucket.fromBucketName(this, 'Bucket', this.deploymentBucketName); const pipelineWithAwaiter = new PipelineWithAwaiter(this, 'ApiPipeline', { skipWait, envName: this.envName, containersInfo, service, bucket, s3SourceActionKey, deploymentMechanism, gitHubSourceActionInfo, desiredCount, }); pipelineWithAwaiter.node.addDependency(service); return { pipelineWithAwaiter }; } protected getPipelineName() { return this.pipelineWithAwaiter.getPipelineName(); } getPipelineConsoleUrl(region: string) { const pipelineName = this.getPipelineName(); return `https://${region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${pipelineName}/view`; } /** * This function renderers a full CFN template for this stack. * It is inspired by * https://github.com/aws/aws-cdk/blob/bd056d1d38a2d3f43efe4f857c4d38b30fb9b681/packages/%40aws-cdk/assertions/lib/template.ts#L298-L310. * This replaces private prepareApp (from CDK v1) and this._toCloudFormation() (the latter does not function properly without the former). */ private renderCfnTemplate(): any { const root = this.node.root as cdk.Stage; const assembly = root.synth(); if (this.nestedStackParent) { // if this is a nested stack (it has a parent), then just read the template as a string return JSON.parse(fs.readFileSync(path.join(assembly.directory, this.templateFile)).toString('utf-8')); } return assembly.getStackArtifact(this.artifactId).template; } toCloudFormation() { this.node .findAll() .filter((construct) => construct instanceof CfnFunction) .map((construct) => construct as CfnFunction) .forEach((lambdaFunction) => { if (lambdaFunction.logicalId.includes('AwaiterMyProvider')) { lambdaFunction.code = { s3Bucket: this.deploymentBucketName, s3Key: this.awaiterS3Key, }; } }); const cfn = this.renderCfnTemplate(); Object.keys(cfn.Parameters).forEach((k) => { if (k.startsWith('AssetParameters')) { delete cfn.Parameters[k]; } }); return cfn; } } /** * Return a {iam.PolicyStatement} from JSON IAM policy. * This allow us tu pass the statements in a way that CDK can use when synthesizing * * @param policy JSON object of IAM policy * @returns {iam.PolicyStatement} CDK policy statement */ function jsonPolicyToCdkPolicyStatement(policy: Record<string, any>): iam.PolicyStatement { return new iam.PolicyStatement({ effect: policy.Effect, actions: Array.isArray(policy.Action) ? policy.Action : [policy.Action], resources: Array.isArray(policy.Resource) ? policy.Resource.map((r) => cdk.Token.asString(r)) : [cdk.Token.asString(policy.Resource)], }); } function isPolicyStatement(obj: any): obj is iam.PolicyStatement { if (obj && typeof (<iam.PolicyStatement>obj).toStatementJson === 'function') { return true; } return false; }