Skip to content

Instantly share code, notes, and snippets.

@sebs
Created April 2, 2022 17:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sebs/f439bd4eb4a80275f6c1728e3c5cda51 to your computer and use it in GitHub Desktop.
Save sebs/f439bd4eb4a80275f6c1728e3c5cda51 to your computer and use it in GitHub Desktop.
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