Last active
February 23, 2024 07:04
-
-
Save arbon/fd001ae9040fc2b675ed31d6f022d784 to your computer and use it in GitHub Desktop.
TypeScript / AWS CDK / Implements various related stacks. Experiments with classical inheritance...
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import { Construct } from 'constructs' | |
import { CfnOutput, Duration, Environment, RemovalPolicy, Stack, Tags } from 'aws-cdk-lib' | |
import { DataClassification, Package, Stage } from '..' | |
import { BlockPublicAccess, Bucket, BucketEncryption, BucketProps, ObjectOwnership, StorageClass } from 'aws-cdk-lib/aws-s3' | |
import { Dashboard, DimensionsMap, GraphWidget, Metric, TextWidget } from 'aws-cdk-lib/aws-cloudwatch' | |
import { ILogGroup } from 'aws-cdk-lib/aws-logs' | |
/** | |
* Defines stack creation properties. | |
*/ | |
export interface AppStackProps { | |
/** | |
* The resource removal policy as a function of `cdk.RemovalPolicy`. | |
* By default, when a resource is removed, it will be physically destroyed. | |
*/ | |
dataRemovalPolicy?: RemovalPolicy | |
/** | |
* The classification of application data. | |
*/ | |
dataClassification?: DataClassification | |
/** | |
* Provides a description of the stack. | |
*/ | |
description?: string | |
/** | |
* Defines the stack environment. | |
*/ | |
env?: Environment | |
/** | |
* The SDLC development stage of the application as defined by `Stage`. | |
*/ | |
stage: Stage | |
} | |
/** | |
* Extends the base cdk.Stack class to provide common features across Stack subclasses. | |
*/ | |
export class AppStack extends Stack implements AppStackProps { | |
/** | |
* The SDLC development stage of the application as defined by `Stage`. | |
*/ | |
readonly stage: Stage | |
/** | |
* The resource removal policy. | |
*/ | |
readonly dataRemovalPolicy: RemovalPolicy | |
/** | |
* The classification of application data. | |
*/ | |
readonly dataClassification: DataClassification | |
/** | |
* The Cloudwatch dashboard for the stack. | |
*/ | |
dashboard: Dashboard | |
/** | |
* Creates a new stack, setting properties defined in `AppStackProps` for subclasses. | |
* Sets the resource data removal policy (for Cloudformation management) and data classification (for tags). | |
* | |
* @param scope Parent of this stack, usually an `App` | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `AppStackProps`. | |
*/ | |
constructor(scope: Construct, id?: string, props: Partial<AppStackProps> = {}) { | |
super(scope, id, props) | |
this.dataClassification = props?.dataClassification || DataClassification.PRIVATE | |
this.dataRemovalPolicy = props?.dataRemovalPolicy || RemovalPolicy.DESTROY | |
this.stage = props?.stage || Stage.DEVELOPMENT | |
// Add package metadata. | |
this.addMetadata('Package', { | |
Name: Package.name, Author: Package.author, Version: Package.version, | |
RepositoryUrl: Package?.repository?.url | |
}) | |
// For now, use our utility method to add tags. Ideally, these are set via props. | |
// See: https://github.com/aws/aws-cdk/issues/20549 | |
this.addTags({ | |
'app:data:classification': this.dataClassification, | |
'app:data:removal-policy': this.dataRemovalPolicy, | |
'app:package:author': Package.author, | |
'app:package:name': Package.name, | |
'app:package:version': Package.version, | |
'app:stage': this.stage, | |
}) | |
this.dashboard = new Dashboard(this, 'Dashboard') | |
this.dashboard.addWidgets(new TextWidget({ | |
markdown: `# ${Package.name}-${Package.version} | |
**Stack** ${this.stackId} / **Region** ${this.region} / **Stage** ${this.stage} / **Data Classification** ${this.dataClassification} / **Removal Policy** ${this.dataRemovalPolicy}`, | |
height: 2, width: 24 | |
})) | |
} | |
/** | |
* Creates metrics associated with Cloudwatch. | |
* Adds metrics to the stack dashboard. | |
*/ | |
createLogMetrics(logGroup: ILogGroup): void { | |
const dimensionsMap: DimensionsMap = { | |
'LogGroupName': logGroup.logGroupName | |
} | |
const incomingBytes = new Metric({ | |
dimensionsMap, | |
namespace: 'AWS/Logs', metricName: 'IncomingBytes', statistic: 'min', | |
}) | |
const incomingLogEvents = new Metric({ | |
dimensionsMap, | |
namespace: 'AWS/Logs', metricName: 'IncomingLogEvents', statistic: 'sum' | |
}) | |
this.dashboard.addWidgets( | |
new GraphWidget({ | |
title: `Cloudwatch / Events (${logGroup.logGroupName})`, | |
width: 12, | |
height: 5, | |
left: [ | |
incomingLogEvents | |
], | |
}), | |
new GraphWidget({ | |
title: `Cloudwatch / Bytes (${logGroup.logGroupName})`, | |
width: 12, | |
height: 5, | |
left: [ | |
incomingBytes, | |
incomingBytes.with({ | |
statistic: 'avg' | |
}), | |
incomingBytes.with({ | |
statistic: 'max' | |
}) | |
] | |
}) | |
) | |
} | |
/** | |
* Tag this resource based on object key-value pairs. | |
* @param {object} tags An object containing properties used to set tags. | |
*/ | |
addTags(tags: Record<string, string> = {}): void { | |
for (const [key, value] of Object.entries(tags)) { | |
Tags.of(this).add(key, value) | |
} | |
} | |
/** | |
* Creates a new encrypted, private bucket. | |
* | |
* @param id The bucket id. | |
* @param bucketProps Bucket properties for custom behavior. | |
* @returns A new bucket. | |
*/ | |
createBucket(id: string, bucketProps?: BucketProps): Bucket { | |
const bucket = new Bucket(this, id, { | |
blockPublicAccess: BlockPublicAccess.BLOCK_ALL, | |
encryption: BucketEncryption.S3_MANAGED, | |
enforceSSL: true, | |
objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED, | |
removalPolicy: this.dataRemovalPolicy, | |
...bucketProps | |
}) | |
this.export(`${id}Arn`, bucket.bucketArn) | |
this.export(`${id}Name`, bucket.bucketName) | |
return bucket | |
} | |
/** | |
* Creates a private, encrypted bucket for logs. | |
* Lifecycle rules are added to expire and transition objects. | |
* | |
* @param id The bucket id. | |
* @param bucketProps Bucket properties for custom behavior. | |
* @returns A new bucket. | |
*/ | |
createLogsBucket(id: string, bucketProps?: BucketProps): Bucket { | |
return this.createBucket(id, { | |
lifecycleRules: [ | |
{ | |
expiration: Duration.days(365) | |
}, | |
{ | |
transitions: [ | |
{ | |
storageClass: StorageClass.INFREQUENT_ACCESS, | |
transitionAfter: Duration.days(60) | |
} | |
] | |
} | |
], | |
...bucketProps | |
}) | |
} | |
/** | |
* Appends a suffix that contains, account, stage and region. | |
* | |
* @param value The base value to use. | |
* @returns The value with a | |
*/ | |
appendSuffix(value: string) { | |
return `${value}-${this.account}-${this.stage}-${this.region}` | |
} | |
/** | |
* Gets a value for the key from the current context, potentially returning a default value. | |
* | |
* @param key The context key to try. | |
* @param defaultValue The default value. | |
* @returns The value for the key or default value, undefined. | |
*/ | |
getContextValue(key: string, defaultValue: string) { | |
return this.node.tryGetContext(key) || defaultValue | |
} | |
/** | |
* Creates a `CfnOutput` value for the stack. | |
* | |
* @param name The name of the output value. | |
* @param value The value to export. | |
* @returns The resulting `CfnOutput` | |
*/ | |
export(name: string, value: string): CfnOutput { | |
return new CfnOutput(this, name, { | |
exportName: `${this.stackName}:${name}`, value | |
}) | |
} | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import { Construct } from 'constructs' | |
import { EmailIdentity, EmailIdentityProps, Identity, ReceiptRuleSet, ReceiptRuleSetProps, TlsPolicy } from 'aws-cdk-lib/aws-ses' | |
import { CfnRecordSetGroup, HostedZone, RecordType } from 'aws-cdk-lib/aws-route53' | |
import { Sns } from 'aws-cdk-lib/aws-ses-actions' | |
import { TopicStack, TopicStackProps } from './TopicStack' | |
import { ServicePrincipal } from 'aws-cdk-lib/aws-iam' | |
/** | |
* Defines properties for a key stack. | |
*/ | |
export interface EmailTopicProps extends TopicStackProps { | |
/** | |
* The subdomain for mail/topic use. | |
*/ | |
subdomain: string | |
/** | |
* An array of names or "local parts" used to build a list of recipients | |
* given the `domainName` and `subdomain`. | |
*/ | |
recipients: string[] | |
/** | |
* The TLD associated with a Route53 hosted zone. | |
*/ | |
domainName: string | |
/** | |
* Email identity properties for custom behavior. | |
*/ | |
emailIdentityProps?: EmailIdentityProps | |
/** | |
* Email rule set properties for custom behavior. | |
*/ | |
receiptRuleSetProps?: ReceiptRuleSetProps | |
} | |
export class EmailTopicStack extends TopicStack { | |
/** | |
* The default DNS subdomain for email to topic delivery. | |
*/ | |
static SUBDOMAIN: string = 'topics' | |
/** | |
* Creates resources to forward email to an SNS topic. Specifically: | |
* | |
* - a new `AWS::SES::EmailIdentity` associated with an existing Route 53 zone. | |
* - a new `AWS::Route53::RecordSetGroup` for subdomain DKIM and MX records. | |
* - a new `AWS::SES::ReceiptRuleSet` that maps addresses to a topic. | |
* - a new or existing `AWS::KMS::Key` for encryption. | |
* - a new or existing `AWS::SNS::Topic` to receive messages. | |
* | |
* NOTE: The SES rule set must be manually activated. | |
* | |
* @param scope Parent of this stack, usually an `App`. | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `EmailTopicProps`. | |
*/ | |
constructor(scope: Construct, id: string, props: Partial<EmailTopicProps> = {}) { | |
super(scope, id, props) | |
props.subdomain = props.subdomain || EmailTopicStack.SUBDOMAIN | |
if (!props.domainName) { | |
throw new Error('A domain name for a Route53 hosted zone is required.') | |
} | |
if (!props.recipients || props.recipients.length < 1) { | |
throw new Error(`One or more recipients are required.`) | |
} | |
// Get the zone from our domain name. | |
const hostedZone = HostedZone.fromLookup(this, 'HostedZone', { | |
domainName: props.domainName | |
}) | |
// Create a new identity for our subdomain. | |
const mailDomain = `${props.subdomain}.${hostedZone.zoneName}` | |
const identity = new EmailIdentity(this, `EmailIdentity`, { | |
identity: Identity.domain(mailDomain), | |
...props.emailIdentityProps | |
}) | |
identity.applyRemovalPolicy(this.dataRemovalPolicy) | |
this.export('EmailIdentityName', identity.emailIdentityName) | |
// Create CNAME records for DKIM values. Add an MX record for inbound mail. | |
const ttl = '600' | |
const recordSetGroup = new CfnRecordSetGroup(this, 'RecordSetGroup', { | |
hostedZoneId: hostedZone.hostedZoneId, | |
recordSets: [{ | |
name: identity.dkimDnsTokenName1, | |
resourceRecords: [identity.dkimDnsTokenValue1], | |
type: RecordType.CNAME, | |
ttl | |
}, | |
{ | |
name: identity.dkimDnsTokenName2, | |
resourceRecords: [identity.dkimDnsTokenValue2], | |
type: RecordType.CNAME, | |
ttl | |
}, | |
{ | |
name: identity.dkimDnsTokenName3, | |
resourceRecords: [identity.dkimDnsTokenValue3], | |
type: RecordType.CNAME, | |
ttl | |
}, | |
{ | |
name: mailDomain, | |
resourceRecords: [`10 inbound-smtp.${this.region}.amazonaws.com`], | |
type: RecordType.MX, | |
ttl | |
} | |
] | |
}) | |
recordSetGroup.applyRemovalPolicy(this.dataRemovalPolicy) | |
recordSetGroup.node.addDependency(identity) | |
// Create a receipt rule set with an SNS action for our topic. | |
const receiptRuleSet = new ReceiptRuleSet(this, 'ReceiptRuleSet', { | |
rules: [{ | |
actions: [ | |
new Sns({ | |
topic: this.topic | |
}) | |
], | |
enabled: true, | |
// Add recipients based on our mail subdomain. | |
recipients: props.recipients.map((value) => { | |
return `${value}@${mailDomain}` | |
}), | |
tlsPolicy: TlsPolicy.REQUIRE | |
}], | |
...props.receiptRuleSetProps | |
}) | |
receiptRuleSet.applyRemovalPolicy(this.dataRemovalPolicy) | |
receiptRuleSet.node.addDependency(this.topic) | |
this.export('ReceiptRuleSetName', receiptRuleSet.receiptRuleSetName) | |
// Update key and topic policies. | |
const sesService = new ServicePrincipal('ses.amazonaws.com') | |
this.topic.grantPublish(sesService) | |
this.key.grantEncryptDecrypt(sesService) | |
console.log('NOTE: When complete, please activate the SES rule set.', | |
`https://${this.region}.console.aws.amazon.com/ses/home?region=${this.region}#/email-receiving`) | |
} | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
export * from "./stacks" | |
import { readFileSync } from 'fs' | |
/** | |
* Defines regions. | |
*/ | |
export class Regions { | |
static US_EAST = ['us-east-1', 'us-east-2'] | |
static US_WEST = ['us-west-1', 'us-west-2'] | |
static US = [...this.US_EAST, ...this.US_WEST] | |
} | |
/** | |
* Provides package metadata. | |
*/ | |
export const Package = JSON.parse(readFileSync('package.json', { | |
encoding: 'utf-8' | |
})) | |
/** | |
* Defines stack application stages. | |
*/ | |
export enum Stage { | |
DEVELOPMENT = 'dev', | |
PRODUCTION = 'prod', | |
STAGING = 'staging' | |
} | |
/** | |
* Defines data classification types for stack metadata. | |
*/ | |
export enum DataClassification { | |
CONFIDENTIAL = 'confidential', | |
INTERNAL = 'internal', | |
PRIVATE = 'private', | |
PUBLIC = 'public', | |
RESTRICTED = 'restricted' | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import { Construct } from 'constructs' | |
import { IKey, Key, KeyProps } from 'aws-cdk-lib/aws-kms' | |
import { AppStack, AppStackProps } from './AppStack' | |
/** | |
* Defines properties for a key stack. | |
*/ | |
export interface KeyStackProps extends AppStackProps { | |
/** | |
* The ARN of an existing KMS key. | |
*/ | |
keyArn?: string | |
/** | |
* The properties for creation of a new KMS key. | |
*/ | |
keyProps?: KeyProps | |
} | |
export class KeyStack extends AppStack { | |
/** The KMS key. */ | |
readonly key: IKey | |
/** | |
* Creates a new `KeyStack`. | |
* | |
* @param scope Parent of this stack, usually an `App`. | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `KeyStackProps`. | |
*/ | |
constructor(scope: Construct, id: string, props: Partial<KeyStackProps> = {}) { | |
super(scope, id, props) | |
// Use an existing key as needed. | |
const key = 'Key' | |
if (props.keyArn) { | |
this.key = Key.fromKeyArn(this, key, props.keyArn) | |
} else { | |
this.key = new Key(this, key, { | |
enableKeyRotation: true, | |
removalPolicy: this.dataRemovalPolicy, | |
...props.keyProps | |
}) | |
} | |
this.export('KeyArn', this.key.keyArn) | |
} | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import { Duration, } from 'aws-cdk-lib' | |
import { Construct } from 'constructs' | |
import { IQueue, Queue, QueueEncryption, QueueProps } from 'aws-cdk-lib/aws-sqs' | |
import { SqsSubscription } from 'aws-cdk-lib/aws-sns-subscriptions' | |
import { TopicStack, TopicStackProps } from './TopicStack' | |
/** | |
* Defines properties for an SQS queue stack. | |
*/ | |
export interface QueueStackProps extends TopicStackProps { | |
/** | |
* Create a dead-letter queue? | |
*/ | |
deadLetterQueueEnabled: boolean | |
/** | |
* Propteries for dead-letter queue creation. | |
*/ | |
deadLetterQueueProps?: QueueProps | |
/** | |
* The ARN of an existing queue to use. | |
*/ | |
queueArn?: string | |
/** | |
* Propteries for queue creation. | |
*/ | |
queueProps?: QueueProps | |
} | |
export class QueueStack extends TopicStack { | |
/** | |
* The SQS queue. | |
*/ | |
readonly queue: IQueue | |
/** | |
* The SQS dead-letter queue. | |
*/ | |
readonly deadLetterQueue: IQueue | |
/** | |
* Creates a new `QueueStack` based on `TopicStack`. | |
* | |
* @param scope Parent of this stack, usually an `App`. | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `TopicStackProps`. | |
*/ | |
constructor(scope: Construct, id: string, props: Partial<QueueStackProps> = {}) { | |
super(scope, id, props) | |
const queue = 'Queue' | |
if (props.queueArn) { | |
this.queue = Queue.fromQueueArn(this, queue, props.queueArn) | |
} else { | |
props.deadLetterQueueEnabled = props.deadLetterQueueEnabled || true | |
if (props.deadLetterQueueEnabled) { | |
this.deadLetterQueue = new Queue(this, 'DeadLetterQueue', { | |
encryption: QueueEncryption.KMS, | |
encryptionMasterKey: this.key, | |
retentionPeriod: Duration.days(14), | |
...props.deadLetterQueueProps | |
}) | |
this.deadLetterQueue.applyRemovalPolicy(this.dataRemovalPolicy) | |
this.export('DeadLetterQueueName', this.deadLetterQueue.queueName) | |
this.export('DeadLetterQueueArn', this.deadLetterQueue.queueArn) | |
this.export('DeadLetterQueueUrl', this.deadLetterQueue.queueUrl) | |
} | |
this.queue = new Queue(this, queue, { | |
encryption: QueueEncryption.KMS, | |
encryptionMasterKey: this.key, | |
deadLetterQueue: props.deadLetterQueueEnabled ? { | |
maxReceiveCount: 5, queue: this.deadLetterQueue | |
} : undefined, | |
retentionPeriod: Duration.days(14), | |
...props.queueProps | |
}) | |
} | |
this.queue.applyRemovalPolicy(this.dataRemovalPolicy) | |
this.topic.addSubscription(new SqsSubscription(this.queue)) | |
this.export('QueueName', this.queue.queueName) | |
this.export('QueueArn', this.queue.queueArn) | |
this.export('QueueUrl', this.queue.queueUrl) | |
} | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import { Construct } from 'constructs' | |
import { ITopic, Topic, TopicProps } from 'aws-cdk-lib/aws-sns' | |
import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions' | |
import { KeyStack, KeyStackProps } from './KeyStack' | |
import { DimensionsMap, GraphWidget, Metric } from 'aws-cdk-lib/aws-cloudwatch' | |
/** | |
* Defines properties for an SNS topic stack. | |
*/ | |
export interface TopicStackProps extends KeyStackProps { | |
/** | |
* The ARN of an existing topic to use. | |
*/ | |
topicArn?: string | |
/** | |
* Propteries for new topic creation. | |
*/ | |
topicProps?: TopicProps | |
/** | |
* Defines one or more email addresses for topic subscription(s). | |
*/ | |
emailSubscribers?: string[] | |
} | |
export class TopicStack extends KeyStack { | |
/** | |
* The SNS topic. | |
*/ | |
readonly topic: ITopic | |
/** | |
* Creates a new `TopicStack` based on `KmsKeyStack`. | |
* | |
* @param scope Parent of this stack, usually an `App`. | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `LogGroupStackProps`. | |
*/ | |
constructor(scope: Construct, id: string, props: Partial<TopicStackProps> = {}) { | |
super(scope, id, props) | |
const topic = 'Topic' | |
if (props.topicArn) { | |
this.topic = Topic.fromTopicArn(this, topic, props.topicArn) | |
} else { | |
this.topic = new Topic(this, topic, { | |
masterKey: this.key, ...props.topicProps | |
}) | |
this.topic.node.addDependency(this.key) | |
this.topic.applyRemovalPolicy(this.dataRemovalPolicy) | |
} | |
props.emailSubscribers && props.emailSubscribers.forEach(emailAddress => { | |
this.topic.addSubscription(new EmailSubscription(emailAddress)) | |
}) | |
this.export('TopicArn', this.topic.topicArn) | |
this.createTopicMetrics(this.topic) | |
} | |
/** | |
* Creates Cloudwatch dashboard widgets. | |
*/ | |
createTopicMetrics(topic: ITopic): void { | |
const dimensionMap: DimensionsMap = { | |
'TopicName': topic.topicName | |
} | |
const publishSize = new Metric({ | |
dimensionsMap: dimensionMap, | |
namespace: 'AWS/SNS', metricName: 'PublishSize', statistic: 'min' | |
}) | |
this.dashboard.addWidgets( | |
new GraphWidget({ | |
title: `SNS / Messages (${topic.topicName})`, | |
width: 12, | |
height: 5, | |
left: [ | |
new Metric({ | |
dimensionsMap: dimensionMap, | |
namespace: 'AWS/SNS', metricName: 'NumberOfMessagesPublished', statistic: 'sum', label: 'Published' | |
}), | |
new Metric({ | |
dimensionsMap: dimensionMap, | |
namespace: 'AWS/SNS', metricName: 'NumberOfNotificationsDelivered', statistic: 'sum', label: 'Delivered' | |
}), | |
], | |
right: [ | |
new Metric({ | |
dimensionsMap: dimensionMap, | |
namespace: 'AWS/SNS', metricName: 'NumberOfNotificationsFailed', statistic: 'sum', label: 'Failed' | |
}) | |
] | |
}), | |
new GraphWidget({ | |
title: `SNS / Message Size (${topic.topicName})`, | |
width: 12, | |
height: 5, | |
left: [ | |
publishSize, | |
publishSize.with({ | |
statistic: 'avg' | |
}), | |
publishSize.with({ | |
statistic: 'max' | |
}) | |
], | |
right: [ | |
publishSize.with({ | |
statistic: 'sum' | |
}), | |
] | |
}) | |
) | |
} | |
} |
This file contains 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
/** | |
* @license | |
* Copyright (c) 2024 Zach Arbon. All rights reserved. | |
* SPDX-License-Identifier: MIT | |
*/ | |
import * as cdk from 'aws-cdk-lib' | |
import * as iam from 'aws-cdk-lib/aws-iam' | |
import * as kms from 'aws-cdk-lib/aws-kms' | |
import * as s3 from 'aws-cdk-lib/aws-s3' | |
import { Construct } from 'constructs' | |
import { AppStack, AppStackProps } from './AppStack' | |
import { GraphWidget, Metric } from 'aws-cdk-lib/aws-cloudwatch' | |
import { CloudWatchStorageType } from '../metrics' | |
/** | |
* Defines properties for a bucket stack. | |
*/ | |
export interface WebBucketStackProps extends AppStackProps { | |
/** | |
* Defines the S3 bucket for regional replication. | |
*/ | |
replicaBucket?: s3.Bucket | undefined | |
/** | |
* Defines the KMS key used for replication bucket encryption. | |
*/ | |
replicaBucketKey?: kms.IKey | undefined | |
/** | |
* Defines the object expiration period. | |
*/ | |
dataExpiration?: cdk.Duration | undefined | |
/** | |
* Defines the object transition period. | |
*/ | |
dataTransition?: cdk.Duration | undefined | |
/** | |
* Defines the storage class to transition to. | |
*/ | |
dataTransitionClass?: s3.StorageClass | undefined | |
/** | |
* Defines the expiration period for older versions of objects. | |
*/ | |
dataVersionExpiration?: cdk.Duration | undefined | |
} | |
/** | |
* Provides a stack that creates S3 buckets for web content. Features include: | |
* - Bucket objects are private and encrypted via SSE with a master key managed by S3. | |
* - Lifecycle rules to transition and expire older objects are defined. | |
* - Cross-region replication is supported given a `replicaBucket` and `replicaBucketKey`. | |
* - Resources implement a standard removal policy. | |
*/ | |
export class WebBucketStack extends AppStack implements WebBucketStackProps { | |
/** | |
* Provides a bucket for logs. | |
*/ | |
logsBucket: s3.Bucket | |
/** | |
* Provides a bucket for content. | |
*/ | |
contentBucket: s3.Bucket | |
/** | |
* The KMS key used for bucket encryption. | |
*/ | |
bucketKey: kms.IKey | |
crossRegionReferences: true | |
dataExpiration: cdk.Duration | |
dataTransition: cdk.Duration | |
dataTransitionClass: s3.StorageClass | |
dataVersionExpiration: cdk.Duration | |
replicaBucket?: s3.Bucket | undefined | |
replicaBucketKey?: kms.IKey | undefined | |
/** | |
* Creates a new web bucket stack with a handful of resources: | |
* - S3 Buckets - One for content and one for server logs. | |
* - IAM Role - Optional for policies associated with replication. | |
* | |
* @param scope Parent of this stack, usually an `App` | |
* @param id The construct ID of this stack. | |
* @param props Stack properties as defined by `WebBucketStackProps`. | |
*/ | |
constructor(scope: Construct, id?: string, props: Partial<WebBucketStackProps> = {}) { | |
super(scope, id, props) | |
this.dataExpiration = props.dataExpiration || cdk.Duration.days(365) | |
this.dataTransition = props.dataTransition || cdk.Duration.days(60) | |
this.dataTransitionClass = props.dataTransitionClass || s3.StorageClass.INFREQUENT_ACCESS | |
this.dataVersionExpiration = props.dataVersionExpiration || cdk.Duration.days(30) | |
this.replicaBucket = props.replicaBucket | |
this.replicaBucketKey = props.replicaBucketKey | |
// Establish the key used for bucket encryption. | |
// TODO: Support other KMS keys; Cloudfront may have issues. | |
this.bucketKey = kms.Key.fromLookup(this, 'Key', { | |
aliasName: 'alias/aws/s3' | |
}) | |
// Create a bucket for server logs? | |
this.logsBucket = this.createLogsBucket('Logs', { | |
bucketName: `${id}-logs-${this.account}-${this.stage}-${this.region}`, | |
}) | |
// Create a bucket for content. | |
const serverAccessLogsPrefix = 'server/' | |
this.contentBucket = this.createBucket('Content', { | |
bucketName: this.appendSuffix(`${id}-content`), | |
lifecycleRules: [ | |
{ | |
noncurrentVersionExpiration: this.dataVersionExpiration | |
} | |
], | |
serverAccessLogsBucket: this.logsBucket, | |
serverAccessLogsPrefix: serverAccessLogsPrefix, | |
versioned: true | |
}) | |
this.createBucketMetrics(this.contentBucket) | |
this.createBucketMetrics(this.logsBucket, CloudWatchStorageType.STANDARD_IA_STORAGE) | |
// Enable regional replication? | |
// Create a replication role and add a replication configuration to the content bucket. | |
if (!this.replicaBucket || !this.replicaBucketKey) { | |
return | |
} | |
const replicationRole = new iam.Role(this, 'ReplicationRole', { | |
assumedBy: new iam.ServicePrincipal('s3.amazonaws.com') | |
}) | |
replicationRole.applyRemovalPolicy(this.dataRemovalPolicy) | |
// Allow retrieval of object lists, tags, ACLs, etc. | |
// See: https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html#amazons3-resources-for-iam-policies | |
replicationRole.addToPolicy(new iam.PolicyStatement({ | |
actions: [ | |
's3:GetObjectVersionAcl', | |
's3:GetObjectVersionForReplication', | |
's3:GetObjectVersionTagging', | |
's3:GetReplicationConfiguration', | |
's3:ListBucket' | |
], | |
resources: [ | |
this.contentBucket.bucketArn, `${this.contentBucket.bucketArn}/*` | |
] | |
})) | |
// Allow decryption via S3 for content bucket objects. | |
replicationRole.addToPolicy(new iam.PolicyStatement({ | |
actions: ['kms:Decrypt'], | |
resources: [this.bucketKey.keyArn], | |
conditions: { | |
StringLike: { | |
'kms:ViaService': `s3.${this.region}.amazonaws.com`, | |
'kms:EncryptionContext:aws:s3:arn': [ | |
`${this.contentBucket.bucketArn}/*` | |
] | |
} | |
} | |
})) | |
// Allow encryption via S3 for replica bucket objects. | |
replicationRole.addToPolicy(new iam.PolicyStatement({ | |
actions: ['kms:Encrypt'], | |
resources: [this.replicaBucketKey.keyArn], | |
conditions: { | |
StringLike: { | |
'kms:ViaService': `s3.${this.region}.amazonaws.com`, | |
'kms:EncryptionContext:aws:s3:arn': [ | |
`${this.replicaBucket.bucketArn}/*` | |
] | |
} | |
} | |
})) | |
// Allow replication actions for replica bucket objects. | |
replicationRole.addToPolicy(new iam.PolicyStatement({ | |
actions: [ | |
's3:ReplicateDelete', | |
's3:ReplicateObject', | |
's3:ReplicateTags' | |
], | |
resources: [`${this.replicaBucket.bucketArn}/*`] | |
})) | |
// Add a replication configuration to the bucket. | |
const bucket = this.contentBucket.node.defaultChild as s3.CfnBucket | |
bucket && bucket.addPropertyOverride('ReplicationConfiguration', { | |
Role: replicationRole.roleArn, | |
Rules: [ | |
{ | |
Destination: { | |
Bucket: this.replicaBucket.bucketArn | |
}, | |
Status: 'Enabled' | |
} | |
] | |
}) | |
} | |
/** | |
* Creates metrics associated with S3 buckets. | |
* Adds metrics to the stack dashboard. | |
*/ | |
createBucketMetrics(bucket: s3.Bucket, | |
storageType: CloudWatchStorageType = CloudWatchStorageType.STANDARD_STORAGE): void { | |
this.dashboard.addWidgets( | |
new GraphWidget({ | |
title: `S3 / Size & Objects (${bucket.bucketName})`, | |
width: 24, | |
height: 6, | |
left: [ | |
new Metric({ | |
dimensionsMap: { | |
'BucketName': bucket.bucketName, 'StorageType': storageType | |
}, | |
namespace: 'AWS/S3', metricName: 'BucketSizeBytes', statistic: 'avg' | |
}) | |
], | |
right: [ | |
new Metric({ | |
dimensionsMap: { | |
'BucketName': bucket.bucketName, 'StorageType': 'AllStorageTypes' | |
}, | |
namespace: 'AWS/S3', metricName: 'NumberOfObjects', statistic: 'avg' | |
}) | |
] | |
}) | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment