Created
April 2, 2022 17:33
-
-
Save sebs/f439bd4eb4a80275f6c1728e3c5cda51 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; | |
import { Repository, IRepository } from 'aws-cdk-lib/aws-codecommit'; | |
import { Construct } from 'constructs'; | |
import { BlockPublicAccess, Bucket } from 'aws-cdk-lib/aws-s3'; | |
import { ARecord, HostedZone, IHostedZone, RecordTarget, ZoneDelegationRecord } from 'aws-cdk-lib/aws-route53'; | |
import { AllowedMethods, Distribution, OriginAccessIdentity, SecurityPolicyProtocol, ViewerProtocolPolicy } from 'aws-cdk-lib/aws-cloudfront'; | |
import { Artifact,Pipeline } from 'aws-cdk-lib/aws-codepipeline'; | |
import { CloudFormationCreateUpdateStackAction, CodeBuildAction, CodeCommitSourceAction, S3DeployAction } from 'aws-cdk-lib/aws-codepipeline-actions'; | |
import { PipelineProject, BuildSpec, LinuxBuildImage } from 'aws-cdk-lib/aws-codebuild'; | |
import { CodePipeline as CodePipelineTarget } from 'aws-cdk-lib/aws-events-targets'; | |
import { Alias, Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'; | |
import { helper as nameIt } from "@sebs-crc/naming-helper"; | |
import { CanonicalUserPrincipal, PolicyStatement } from 'aws-cdk-lib/aws-iam'; | |
import { DnsValidatedCertificate } from 'aws-cdk-lib/aws-certificatemanager'; | |
import { S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; | |
import { ApiGateway, CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; | |
import { LambdaDeploymentConfig, LambdaDeploymentGroup } from 'aws-cdk-lib/aws-codedeploy'; | |
import { IdentitySource, LambdaIntegration, RequestAuthorizer, RestApi } from 'aws-cdk-lib/aws-apigateway'; | |
import * as cf from 'aws-cdk-lib/aws-cloudfront'; | |
import { EdgeLambda } from 'aws-cdk-lib/aws-cloudfront'; | |
import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; | |
export interface AwsStackprops extends StackProps { | |
buildStage: string | |
bucketName: string | |
domainName: string | |
siteSubDomain: string | |
env: { | |
account: string | |
region: string | |
} | |
} | |
/** | |
* The stack describing the whole AWS infrastructure required for the #cloudresumechallenge | |
*/ | |
export class AwsStack extends Stack { | |
/** S3 Bucket with the html pages */ | |
public siteBucket: Bucket; | |
/** S3 Bucket with Build Artifacts */ | |
public artifactsBucket: Bucket; | |
/** S3 Bucket Storing the last status information from twitch */ | |
public statusBucket: Bucket; | |
/** Determined by stage the branch which the ci reacts to */ | |
public readonly buildBranch: string; | |
/**complete domain where the page is served from */ | |
public readonly siteDomain: string; | |
/**complete domain where the api is served from */ | |
public readonly apiDomain: string; | |
/** prefix for resource names */ | |
public readonly namePrefix: string; | |
/** Lambda Function that stores twitch events */ | |
private storeEventLambda: Function; | |
/** Lambda Function that checks twitch authroization on webhooks */ | |
private twitchAuthorizerLambda: Function; | |
/** Optional Lambda Function that checks basic auth for web content */ | |
private basicAuthLambda: cf.experimental.EdgeFunction; | |
/** Source code Artifacts */ | |
private artifactSource: Artifact; | |
/** build Artifacts */ | |
private artifactBuild: Artifact; | |
/** build Artifacts */ | |
private zone: IHostedZone; | |
/** site certificate */ | |
private certificate: DnsValidatedCertificate; | |
/** Twitch Secret */ | |
private secretTwitchKey: Secret; | |
/** Basic Auth Password */ | |
private secretBasicAuthPass: Secret; | |
constructor(scope: Construct, id: string, props: AwsStackprops) { | |
super(scope, id, props); | |
// we build either staging or production | |
this.buildBranch = props.buildStage === 'production' ? 'production' : 'master'; | |
// the bucketName is the prefix for all resources | |
this.namePrefix = props.bucketName; | |
// suffix the subdomains with staging for staging deploy | |
const apiSubdomain = props.buildStage !== 'production' ? `${props.siteSubDomain}-api-staging` : props.siteSubDomain + '-api'; | |
const siteSubDomain = props.buildStage !== 'production' ? `${props.siteSubDomain}-staging` : props.siteSubDomain; | |
// create the sitedomain including subdomain and make it testable | |
this.siteDomain = siteSubDomain + '.' + props.domainName; | |
// create the api subdomain | |
this.apiDomain = apiSubdomain + '.' + props.domainName; | |
// get the repository. it must exist under that name | |
const repo = Repository.fromRepositoryName(this, 'Repository', 'cloudresume-challenge'); | |
// lookup the hosted zone of the toplevel domain | |
this.zone = HostedZone.fromLookup(this, nameIt(`${this.namePrefix}-zone`, props).cf, { | |
domainName: props.domainName | |
}); | |
// get the domain root cert | |
const certificateName = nameIt(`${this.namePrefix}-cloudfront-certificate`, props); | |
this.certificate = new DnsValidatedCertificate(this, certificateName.cf, { | |
domainName: this.siteDomain, | |
hostedZone: this.zone, | |
region: 'us-east-1', // Cloudfront only checks this region for certificates. | |
}); | |
// store sourcecode from git | |
this.artifactSource = new Artifact('source'); | |
// store build results | |
this.artifactBuild = new Artifact('build'); | |
// start by settiing up s3 buckets | |
this.setupBuckets(props); | |
this.setupSecrets(props); | |
// Lambda function and Deploy info | |
this.setupLambdas(props); | |
// cdn rewrite rules etc | |
this.setupCdn(props); | |
// lambda Apigateway integration | |
// this.setupApigateway(props); | |
// at last a pipeline to deploy the resources | |
this.setupPipeline(repo, props); | |
} | |
/** | |
* Creates the ApiGateway for the twicth events | |
* Ĺink: https://dev.twitch.tv/docs/eventsub/handling-webhook-events | |
* Link: https://github.com/aws-samples/aws-cdk-examples/blob/1dcf893b1850af518075a24b677539fbbf71a475/typescript/my-widget-service/widget_service.ts | |
* @param props | |
*/ | |
setupApigateway(props: AwsStackprops): ApiGateway { | |
const projectName = nameIt(`${this.namePrefix}-api`, props); | |
const api = new RestApi(this, projectName.cf, { | |
restApiName: projectName.dashed, | |
description: "webhook for twitch" | |
}); | |
api.addDomainName | |
const postWebhook = new LambdaIntegration(this.storeEventLambda, { | |
requestTemplates: { "application/json": '{ "statusCode": "200" }' }, | |
}); | |
// the authorizer can not access the body to check the has | |
// so we have to resort to validate all required parameters are present | |
const identitySources = [ | |
'Twitch-Eventsub-Message-Id', | |
'Twitch-Eventsub-Message-Retry', | |
'Twitch-Eventsub-Message-Type', | |
'Twitch-Eventsub-Message-Signature', | |
'Twitch-Eventsub-Message-Timestamp', | |
'Twitch-Eventsub-Subscription-Type', | |
'Twitch-Eventsub-Subscription-Version' | |
].map((headerName: string)=>IdentitySource.header(headerName)) | |
const authorizerName = nameIt(`${this.namePrefix}-api-authorizer`, props); | |
const authorizer = new RequestAuthorizer(this, authorizerName.cf, { | |
handler: this.twitchAuthorizerLambda, | |
identitySources | |
}); | |
api.root.addMethod("POST", postWebhook, { authorizer }); | |
const dnsRecordName = nameIt(`${this.namePrefix}-api`, props); | |
api.addDomainName(dnsRecordName.cf, { | |
domainName: this.apiDomain, | |
certificate: this.certificate | |
}) | |
return new ApiGateway(api); | |
} | |
/** | |
* Sets up the 'store-event' and 'twitch-authorizer' lambda | |
* @param props | |
*/ | |
setupLambdas(props: AwsStackprops) { | |
this.storeEventLambda = this.createLambdaFunction('store-event', props) as Function; | |
this.twitchAuthorizerLambda = this.createLambdaFunction('twitch-authorizer', props) as Function; | |
this.basicAuthLambda = this.createLambdaFunction('basic-auth', props) as cf.experimental.EdgeFunction; | |
} | |
/** | |
* Creates a codeBuild Action to be used in a pipeline for lambdas in /packages | |
* @param lambdaName | |
* @param props | |
* @returns | |
*/ | |
createLambdaBuildAction(lambdaName: string, props: AwsStackprops): CodeBuildAction { | |
const projectName = nameIt(`${this.namePrefix}-${lambdaName}-project`, props); | |
const actionName = nameIt(`${this.namePrefix}-${lambdaName}-action`, props); | |
// locate the builf file from the lambda folder in /packages | |
const buildSpec = BuildSpec.fromSourceFilename(`./packages/lambda-${lambdaName}/buildspec.yml`); | |
// create an output Artifact | |
const outputArtifact = new Artifact(lambdaName); | |
// create the pipeline Project | |
const project = new PipelineProject(this, projectName.cf, { | |
projectName: projectName.dashed, | |
buildSpec, | |
environment: { | |
buildImage: LinuxBuildImage.STANDARD_5_0 // for node 14 | |
} | |
}); | |
return new CodeBuildAction({ | |
actionName: actionName.cf, | |
project, | |
input: this.artifactSource, | |
outputs: [outputArtifact] | |
}) | |
} | |
/** | |
* Creates a Lambda function from /packages | |
* Check: https://docs.aws.amazon.com/cdk/api/v1/docs/aws-lambda-readme.html | |
* @param lambdaName | |
* @param props | |
* @returns | |
*/ | |
createLambdaFunction(lambdaName: string, props: AwsStackprops): Function | cf.experimental.EdgeFunction { | |
// naming | |
const functionName = nameIt(`${this.namePrefix}-lambda-${lambdaName}`, props); | |
// locate the assets relative to itself | |
const assetLocation = `../lambda-${lambdaName}/lib`; | |
// handler that is getting executed incl function name | |
const handler = `lambda-${lambdaName}.handler`; | |
// the lambda Function itself | |
var lambdaFunction: Function | cf.experimental.EdgeFunction; | |
const secretTwitchKeyName = nameIt(`${this.namePrefix}-twitch-secret`, props); | |
const environments = new Map(); | |
environments.set('twitch-authorizer', {}); | |
// store event laods the secret from the secrets manager | |
environments.set('store-event', { | |
SECRET_NAME: secretTwitchKeyName.dashed | |
}); | |
if (lambdaName == 'basic-auth') { | |
lambdaFunction = new cf.experimental.EdgeFunction(this, functionName.cf, { | |
functionName: functionName.dashed, | |
code: Code.fromAsset(assetLocation), | |
handler: handler, | |
runtime: Runtime.NODEJS_14_X, | |
currentVersionOptions: { | |
removalPolicy: RemovalPolicy.DESTROY | |
} | |
}); | |
} else { | |
lambdaFunction = new Function(this, functionName.cf, { | |
functionName: functionName.dashed, | |
code: Code.fromAsset(assetLocation), | |
handler: handler, | |
runtime: Runtime.NODEJS_14_X, | |
environment: environments.get(environments.get(lambdaName)), | |
}); | |
} | |
if (lambdaName != 'basic-auth') { | |
// deplooy alias that reflects the given build stage | |
const aliasName = nameIt(`${this.namePrefix}-lambda-${lambdaName}-alias`, props); | |
// lambda at dge can not use latest and needs to use current | |
const version = lambdaName == 'basic-auth' ? lambdaFunction.currentVersion : lambdaFunction.latestVersion; | |
const alias = new Alias(this, aliasName.cf, { | |
aliasName: props.buildStage, | |
version | |
}); | |
// deployment group that enforces deployment | |
const deploymentGroupName = nameIt(`${this.namePrefix}-lambda-${lambdaName}-deployment-group`, props); | |
new LambdaDeploymentGroup(this, deploymentGroupName.cf, { | |
alias, | |
deploymentConfig: LambdaDeploymentConfig.ALL_AT_ONCE | |
}); | |
} | |
return lambdaFunction; | |
} | |
/** | |
* Creates a Update Action for this CDK Stack | |
* @param props | |
*/ | |
createStackUpdateAction(props: AwsStackprops) { | |
const stackName = nameIt(props.bucketName, props); | |
const artifact = this.artifactBuild; | |
const templatePath = artifact.atPath(`./packages/aws/cdk.out/${stackName.cf}.template.json`); | |
const actionName = nameIt(`${this.namePrefix}-stack-deploy`, props); | |
} | |
setupSecrets(props: AwsStackprops) { | |
const secretTwitchKeyName = nameIt(`${this.namePrefix}-twitch-secret`, props); | |
this.secretTwitchKey = new Secret(this, secretTwitchKeyName.cf, { | |
replicaRegions: [ | |
{ | |
region: 'us-east-1' | |
} | |
] | |
}); | |
const secretBasicAuthPassName = nameIt(`${this.namePrefix}-basic-auth-secret`, props); | |
this.secretBasicAuthPass = new Secret(this, secretBasicAuthPassName.cf, { | |
replicaRegions: [ | |
{ | |
region: 'us-east-1' | |
} | |
] | |
}); | |
} | |
/** | |
* | |
* @param props | |
*/ | |
setupCdn(props: AwsStackprops) { | |
// Interaction between cloudfront and and the S3 buckets is managed with this identity | |
const cloudfrontOaiName = nameIt(`${this.namePrefix}-cloudfront-oai`, props); | |
const cloudfrontOAI = new OriginAccessIdentity(this, cloudfrontOaiName.cf, { | |
comment: `OAI for ${cloudfrontOaiName.dashed}` | |
}); | |
// Grant the sitebucket access to cloudfront | |
this.siteBucket.addToResourcePolicy(new PolicyStatement({ | |
actions: ['s3:GetObject'], | |
resources: [this.siteBucket.arnForObjects('*')], | |
principals: [new CanonicalUserPrincipal(cloudfrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId)] | |
})); | |
const edgeLambda: EdgeLambda = { | |
eventType: cf.LambdaEdgeEventType.VIEWER_REQUEST, | |
functionVersion: this.basicAuthLambda.currentVersion, | |
includeBody: false | |
} | |
// Super simple cloudfront distribution | |
const distributionName = nameIt(`${this.namePrefix}-cloudfront-distribution`, props); | |
const distribution = new Distribution(this, distributionName.cf, { | |
certificate: this.certificate, | |
defaultRootObject: "index.html", | |
domainNames: [this.siteDomain], | |
minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021, | |
errorResponses:[ | |
{ | |
httpStatus: 403, | |
responseHttpStatus: 403, | |
responsePagePath: '/error/index.html', | |
ttl: Duration.minutes(30), | |
} | |
], | |
defaultBehavior: { | |
origin: new S3Origin(this.siteBucket, {originAccessIdentity: cloudfrontOAI}), | |
compress: true, | |
allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, | |
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, | |
edgeLambdas: [ | |
edgeLambda | |
] | |
} | |
}) | |
// create the subdomain | |
const ARecordName = nameIt(`${this.namePrefix}-cloudfront-arecord`, props); | |
// Route53 alias record for the CloudFront distribution | |
new ARecord(this, ARecordName.cf, { | |
recordName: this.siteDomain, | |
target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)), | |
zone: this.zone | |
}); | |
} | |
/** | |
* Setup all required S3 Buckets | |
* @param props | |
*/ | |
setupBuckets(props: AwsStackprops) { | |
// build artifacts | |
const artifactsBucketName = nameIt(`${this.namePrefix}-artifacts`, props); | |
this.artifactsBucket = new Bucket(this, artifactsBucketName.cf, { | |
bucketName: artifactsBucketName.dashed, | |
publicReadAccess: false, | |
blockPublicAccess: BlockPublicAccess.BLOCK_ALL | |
}); | |
// webiste content | |
const siteBucketName = nameIt(`${this.namePrefix}-site`, props); | |
this.siteBucket = new Bucket(this, siteBucketName.cf, { | |
bucketName: siteBucketName.dashed, | |
publicReadAccess: false, | |
blockPublicAccess: BlockPublicAccess.BLOCK_ALL | |
}); | |
// Data from twitch webhook calls | |
const statusBucketName = nameIt(`${this.namePrefix}-status`, props); | |
this.statusBucket = new Bucket(this, statusBucketName.cf, { | |
bucketName: statusBucketName.dashed, | |
publicReadAccess: false, | |
blockPublicAccess: BlockPublicAccess.BLOCK_ALL | |
}); | |
} | |
/** | |
* Sets a CodeBuild Pipeline up | |
* @param repo | |
* @param props | |
*/ | |
setupPipeline(repo: IRepository, props: AwsStackprops) { | |
// for each lambda create a build action so we can run tests etc | |
const storeEventBuildAction = this.createLambdaBuildAction('store-event', props); | |
const twitchAuthorizerBuildAction = this.createLambdaBuildAction('twitch-authorizer', props); | |
const basicAuthBuildAction = this.createLambdaBuildAction('basic-auth', props); | |
// a rule for the status bucket to re-deploy the webite and contain the latest twitch status | |
const writeRuleName = nameIt(`${this.namePrefix}-on-object-write-rule`, props); | |
const writeRule = this.statusBucket.onCloudTrailWriteObject(writeRuleName.cf, { | |
description: 'Trigger codepipeline when s3 object is created', | |
}); | |
// define pipeline and tell it where artifats go | |
const pipelineName = nameIt(`${this.namePrefix}-pipeline`, props); | |
const pipeline = new Pipeline(this, pipelineName.cf, { | |
pipelineName: pipelineName.dashed, | |
artifactBucket: this.artifactsBucket, | |
}); | |
// when the status S3 Bucket gets a new file, the pipeline is triggered | |
writeRule.addTarget(new CodePipelineTarget(pipeline)) | |
// checkout from aws CodeCommit | |
const stageSource = pipeline.addStage({ | |
stageName: 'Source', | |
actions: [ | |
new CodeCommitSourceAction({ | |
actionName: 'Checkout', | |
repository: repo, | |
output: this.artifactSource, | |
branch: this.buildBranch | |
}) | |
], | |
}); | |
// build the project | |
const projectName = nameIt(`${this.namePrefix}-build-site`, props); | |
// just build the lerna project so we dont hhave to repeat our selves later | |
pipeline.addStage({ | |
stageName: 'Build', | |
actions: [ | |
new CodeBuildAction({ | |
actionName: 'Build', | |
input: this.artifactSource, | |
outputs: [ | |
this.artifactBuild | |
], | |
project: new PipelineProject(this, projectName.cf, { | |
projectName: projectName.dashed, | |
buildSpec: BuildSpec.fromSourceFilename('./packages/aws/lib/build-website.yml'), | |
environment: { | |
buildImage: LinuxBuildImage.STANDARD_5_0 // for node 14 | |
} | |
}) | |
}), | |
storeEventBuildAction, | |
twitchAuthorizerBuildAction, | |
basicAuthBuildAction | |
], | |
}); | |
// depoloy everything | |
pipeline.addStage({ | |
stageName: 'Deploy', | |
actions: [ | |
// deploy the website | |
new S3DeployAction({ | |
actionName: 'PushToS3', | |
input: this.artifactBuild, | |
bucket: this.siteBucket, | |
runOrder: 3, | |
}) | |
] | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment