packages/deploy-aws/src/seagull_stack.ts
import {
App,
RemovalPolicy as RemPolicy,
SecretParameter,
Stack,
StackProps,
} from '@aws-cdk/cdk'
import { LambdaIntegration, RestApi } from '@aws-cdk/aws-apigateway'
import * as CM from '@aws-cdk/aws-certificatemanager'
import * as CF from '@aws-cdk/aws-cloudfront'
import { CloudFrontWebDistributionProps } from '@aws-cdk/aws-cloudfront'
import * as CB from '@aws-cdk/aws-codebuild'
import { GitHubSourceAction, Pipeline, Stage } from '@aws-cdk/aws-codepipeline'
import * as Events from '@aws-cdk/aws-events'
import * as IAM from '@aws-cdk/aws-iam'
import {
Code,
Function as Lambda,
Runtime,
RuntimeFamily,
} from '@aws-cdk/aws-lambda'
import { LogGroup } from '@aws-cdk/aws-logs'
import * as S3 from '@aws-cdk/aws-s3'
import * as EC2 from '@aws-cdk/aws-ec2'
import {
getApiGatewayDomain,
getApiGatewayPath,
mapEnvironmentVariables,
} from './lib'
import { compact } from 'lodash'
import {
BuildStageConfig,
CloudfrontProps,
Keymap,
Rule,
SourceStageConfig,
} from './types'
import { LinuxBuildImage } from '@aws-cdk/aws-codebuild'
/**
* The Seagull Stack - including convenience functions to add resources
*
*/
export class SeagullStack extends Stack {
constructor(app: App, projectName: string, stackProps?: StackProps) {
super(app, projectName, stackProps)
}
addLogGroup(logGroupName: string) {
const retentionDays = Infinity
const props = { logGroupName, retentionDays, retainLogGroup: false }
return new LogGroup(this, `${this.id}-${logGroupName}`, props)
}
addS3(bucketName: string, role?: IAM.IPrincipal, keepBucket = true) {
const removalPolicy = keepBucket ? RemPolicy.Orphan : RemPolicy.Destroy
const s3Props = { bucketName, removalPolicy }
const bucket = new S3.Bucket(this, `${this.id}-${bucketName}`, s3Props)
// tslint:disable-next-line:no-unused-expression
role && bucket.grantReadWrite(role)
return bucket
}
addVPC(
name: string,
vpcId: string,
privateSubnetIds: string[],
availabilityZones: string[]
) {
const props = {
vpcId,
availabilityZones,
privateSubnetIds,
}
return EC2.VpcNetworkRef.import(this, name, props)
}
addLambda(
name: string,
folder: string,
role: IAM.Role,
vpc: EC2.VpcNetworkRef,
env: Keymap
) {
const lambdaName = `${this.id}-${name}`
const securityGroup = EC2.SecurityGroupRef.import(this, 'SecurityGroup', {
securityGroupId: 'sg-06ee9962c3d90de3e',
})
const conf = {
code: Code.asset(`${folder}/.seagull/deploy`),
description: 'universal route',
environment: env,
functionName: `${lambdaName}-handler`,
handler: 'dist/assets/backend/lambda.handler',
memorySize: 1536,
role,
runtime: new Runtime('nodejs16.x', RuntimeFamily.NodeJS, {
supportsInlineCode: true,
}),
vpc,
securityGroup,
timeout: 300,
}
return new Lambda(this, lambdaName, conf)
}
addUniversalApiGateway(apiGWName: string, lambda: Lambda, stageName: string) {
const name = `${this.id}-${apiGWName}`
const defaultIntegration = new LambdaIntegration(lambda)
const conf = { binaryMediaTypes: ['*/*'], deployOptions: { stageName } }
const apiGateway = new RestApi(this, name, conf)
const proxy = apiGateway.root.addResource('{any+}')
apiGateway.root.addMethod('GET', defaultIntegration)
apiGateway.root.addMethod('POST', defaultIntegration)
apiGateway.root.addMethod('DELETE', defaultIntegration)
proxy.addMethod('GET', defaultIntegration)
proxy.addMethod('POST', defaultIntegration)
proxy.addMethod('DELETE', defaultIntegration)
return apiGateway
}
addIAMRole(roleName: string, principleName: string, actions: string[]) {
const name = `${this.id}-${roleName}`
const roleParams = { assumedBy: new IAM.ServicePrincipal(principleName) }
const role = new IAM.Role(this, name, roleParams)
const policyStatement = new IAM.PolicyStatement()
// TODO: add actions directly to the resources that need it
role.addToPolicy(policyStatement.addAllResources().addActions(...actions))
return role
}
addCloudfront(cfdName: string, props: CloudfrontProps) {
const name = `${this.id}-${cfdName}`
const domainName = getApiGatewayDomain(props.apiGateway.url)
const originPath = getApiGatewayPath(props.apiGateway.url)
const behaviors = [this.getDefaultBehavior()]
const customOriginSource = { domainName }
const errorPageConfig =
props.errorBucket && this.getErrorPageConfig(props.errorBucket)
const originConfigurations = compact([
{ behaviors, customOriginSource, originPath },
errorPageConfig,
])
const errorConfig = this.getErrorConfig(500, 500, '/error.html', 60)
const conf: CloudFrontWebDistributionProps = {
aliasConfiguration: props.aliasConfig,
comment: this.id,
defaultRootObject: '',
errorConfigurations: [errorConfig],
loggingConfig: props.logBucket ? { bucket: props.logBucket } : {},
originConfigs: originConfigurations,
}
return new CF.CloudFrontWebDistribution(this, name, conf)
}
addPipeline(pipelineName: string) {
const name = `${this.id}-${pipelineName}`
return new Pipeline(this, name, { pipelineName: name })
}
getErrorPageConfig(bucket: S3.Bucket): CF.SourceConfiguration {
const errorBehavior: CF.Behavior = {
allowedMethods: CF.CloudFrontAllowedMethods.ALL,
compress: true,
forwardedValues: { headers: ['authorization'], queryString: true },
isDefaultBehavior: false,
pathPattern: '/error*',
}
const s3OriginConfig: CF.S3OriginConfig = {
s3BucketSource: bucket,
}
const errorPageConfig: CF.SourceConfiguration = {
behaviors: [errorBehavior],
s3OriginSource: s3OriginConfig,
}
return errorPageConfig
}
getDefaultBehavior() {
return {
allowedMethods: CF.CloudFrontAllowedMethods.ALL,
compress: true,
forwardedValues: { headers: ['authorization'], queryString: true },
isDefaultBehavior: true,
}
}
getErrorConfig(
code: number,
responseCode: number,
responsePagePath: string,
cachingTtl: number
) {
return {
errorCachingMinTtl: cachingTtl,
errorCode: code,
responseCode,
responsePagePath,
}
}
addSourceStage(name: string, config: SourceStageConfig) {
const stageName = name
const sourceName = `${this.id}-github-${name}`
const { atIndex, branch, owner, pipeline, poll, repo, oauthToken } = config
const stage = pipeline.addStage(stageName, { placement: { atIndex } })
const stageConfig = {
branch,
oauthToken,
outputArtifactName: name,
owner,
pollForSourceChanges: poll,
repo,
stage,
}
return new GitHubSourceAction(this, sourceName, stageConfig)
}
addGenericBuildActionStage(name: string, config: BuildStageConfig) {
const stageName = name
const projectName = `${this.id}-project-${name}`
const { atIndex, pipeline } = config
const projectConfig = this.createProjectConfig(config)
const additionalOutputArtifactNames = this.getAdditionalOutputArtifactNames(
name,
config
)
const project = new CB.PipelineProject(this, projectName, projectConfig)
const stage = pipeline.addStage(stageName, { placement: { atIndex } })
const stageConfig = {
additionalInputArtifacts: config.additionalInputArtifacts,
additionalOutputArtifactNames,
inputArtifact: config.inputArtifact,
outputArtifactName: name,
project,
stage,
}
return new CB.PipelineBuildAction(this, name, stageConfig)
}
addDeployActionStage(name: string, config: BuildStageConfig) {
const stageName = name
const projectName = `${this.id}-project-${name}`
const { atIndex, pipeline } = config
const projectConfig = this.createProjectConfig(config)
const additionalOutputArtifactNames = this.getAdditionalOutputArtifactNames(
name,
config
)
const project = new CB.PipelineProject(this, projectName, projectConfig)
const stage = pipeline.addStage(stageName, { placement: { atIndex } })
const stageConfig = {
additionalInputArtifacts: config.additionalInputArtifacts,
additionalOutputArtifactNames,
inputArtifact: config.inputArtifact,
outputArtifactName: name,
project,
stage,
}
return new CB.PipelineBuildAction(this, name, stageConfig)
}
addTestAction(name: string, stage: Stage, config: BuildStageConfig) {
const projectName = `${this.id}-project-${name}`
const projectConfig = this.createProjectConfig(config)
const additionalOutputArtifactNames = this.getAdditionalOutputArtifactNames(
name,
config
)
const project = new CB.PipelineProject(this, projectName, projectConfig)
const stageConfig = {
additionalInputArtifacts: config.additionalInputArtifacts,
additionalOutputArtifactNames,
inputArtifact: config.inputArtifact,
outputArtifactName: name,
project,
stage,
}
return new CB.PipelineBuildAction(this, name, stageConfig)
}
addBuildActionStage(
name: string,
config: BuildStageConfig,
testConfig: BuildStageConfig,
workerCount: number
) {
const stage = config.pipeline.addStage(name, {
placement: { atIndex: config.atIndex },
})
const parallelBuildActions = workerCount
const actions = [...Array(parallelBuildActions).keys()].map(current =>
this.addBuildAction(name, stage, config, {
count: parallelBuildActions,
current,
})
)
this.addTestAction('test', stage, testConfig)
return actions
}
addBuildAction(
name: string,
stage: Stage,
config: BuildStageConfig,
workerMeta: { current: number; count: number }
) {
const projectName = `${this.id}-project-${name}-${workerMeta.current}`
config.env.variables.BUILD_WORKER_ID = workerMeta.current.toString()
config.env.variables.BUILD_WORKER_COUNT = workerMeta.count.toString()
const projectConfig = this.createProjectConfig(config)
const project = new CB.PipelineProject(this, projectName, projectConfig)
const stageConfig: CB.PipelineBuildActionProps = {
additionalInputArtifacts: config.additionalInputArtifacts,
additionalOutputArtifactNames: [`dist${workerMeta.current}`],
inputArtifact: config.inputArtifact,
outputArtifactName: `build${workerMeta.current}`,
project,
stage,
}
const action = new CB.PipelineBuildAction(
this,
`${name}-${workerMeta.current}`,
stageConfig
)
return action
}
addSecretParam(name: string, ssmParameter: string) {
const secretConfig = { ssmParameter }
return new SecretParameter(this, name, secretConfig)
}
addNewCert(name: string, domains: string[]) {
const domainName = domains[0]
const altNames = domains.slice(1)
const subjectAlternativeNames = altNames.length > 0 ? altNames : undefined
const props: CM.CertificateProps = { domainName, subjectAlternativeNames }
return new CM.Certificate(this, `${this.id}-${name}`, props)
}
importS3(bucketName: string, role?: IAM.IPrincipal) {
const name = `${this.id}-${bucketName}`
const bucketArn = `arn:aws:s3:::${bucketName}`
const bucket = S3.Bucket.import(this, name, { bucketArn })
// tslint:disable-next-line:no-unused-expression
role && bucket.grantReadWrite(role)
return bucket
}
addEventRule(rule: Rule, target: Events.IEventRuleTarget) {
const name = rule.path.split('/').pop() || 'rootPath'
const schedule = {
scheduleExpression: rule.cron,
}
const eventRule = new Events.EventRule(this, name, schedule)
eventRule.addTarget(target, { jsonTemplate: `{"path":"${rule.path}"}` })
return eventRule
}
getLogBucketConfig(name: string | undefined) {
const bucket = name ? this.addS3(name) : false
return bucket ? { bucket } : {}
}
private createProjectConfig(config: BuildStageConfig) {
const allowedComputeTypeSizes = ['SMALL', 'MEDIUM', 'LARGE']
const { build, env, install, postBuild, role } = config
const buildImage = new (LinuxBuildImage as any)(
'aws/codebuild/standard:6.0'
) as LinuxBuildImage
const computeType = allowedComputeTypeSizes.includes(config.computeTypeSize)
? `BUILD_GENERAL1_${config.computeTypeSize}`
: CB.ComputeType.Small
const phases = { build, install, post_build: postBuild }
return {
buildSpec: {
artifacts: config.outputArtifacts,
env,
phases,
version: '0.2',
},
environment: { buildImage, computeType },
environmentVariables: mapEnvironmentVariables(env.variables),
role,
} as CB.ProjectProps
}
private getAdditionalOutputArtifactNames(
name: string,
config: BuildStageConfig
): string[] {
if (config.outputArtifacts) {
return Object.keys(config.outputArtifacts['secondary-artifacts'])
.filter(key => key !== name)
.map(key => config.outputArtifacts['secondary-artifacts'][key].name)
}
return []
}
}