@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
583 lines (495 loc) • 18.8 kB
text/typescript
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;
}