@paulbarmstrong/cdk-managed-objects-bucket
Version:
A CDK construct representing a bucket and the objects within it, which can be defined by an Asset or directly in the CDK. It extends the Bucket construct.
217 lines (201 loc) • 7.83 kB
text/typescript
import { execSync } from "child_process"
import path from "path"
import { Construct } from "constructs"
import { readdirSync } from "fs"
import * as cdk from "aws-cdk-lib"
import * as s3 from "aws-cdk-lib/aws-s3"
import * as cloudfront from "aws-cdk-lib/aws-cloudfront"
import * as lambda from "aws-cdk-lib/aws-lambda"
import * as iam from "aws-cdk-lib/aws-iam"
import * as logs from "aws-cdk-lib/aws-logs"
import * as s3_assets from "aws-cdk-lib/aws-s3-assets"
export type ManagedObjectsBucketProps = Partial<Omit<Omit<s3.BucketProps, "removalPolicy">, "autoDeleteObjects">> & {
/** CloudWatch Log Group for the bucket object manager to send its logs to. */
objectManagerLogGroup?: logs.ILogGroup
}
/** @hidden */
type InlineBucketObject = {
key: string,
body: string
}
type CloudFrontInvalidationObjectChangeActionProps = {
/** CloudFront Distribution to create an invalidation for. */
distribution: cloudfront.Distribution,
/**
* Whether to wait for the invalidation to be completed before allowing the CloudFormation
* update to continue.
* @default false
*/
waitForCompletion?: boolean
}
/**
* An action to be performed when changes are made to the objects in the bucket.
*/
export abstract class ObjectChangeAction {
/** @hidden */
#classname = "ObjectChangeAction"
/** ObjectChangeAction for performing a CloudFront invalidation after objects
* in the bucket have changed. */
static cloudFrontInvalidation(props: CloudFrontInvalidationObjectChangeActionProps) {
return new CloudFrontInvalidationObjectChangeAction(props)
}
}
/** ObjectChangeAction for performing an invalidation on a CloudFront distribution after objects
* in the bucket have changed. */
class CloudFrontInvalidationObjectChangeAction extends ObjectChangeAction {
distribution: cloudfront.Distribution
waitForCompletion?: boolean
constructor(props: CloudFrontInvalidationObjectChangeActionProps) {
super()
this.distribution = props.distribution
this.waitForCompletion = props.waitForCompletion
}
}
/**
* An S3 Bucket where the objects in the bucket are completely managed by CDK. A
* `Custom::ManagedBucketObjects` CFN resource internal to the ManagedObjectsBucket construct
* mutates objects in the bucket to align the bucket with the objects defined in the CDK
* definition. The objects in the bucket are otherwise read-only. Objects are added by calling
* the `addObject` and `addObjectsFromAsset` methods.
*
* ManagedObjectsBucket extends [Bucket](
* https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.Bucket.html). All props from
* Bucket are allowed except:
*
* 1. `removalPolicy` and `autoDeleteObjects` are not configurable. ManagedObjectsBuckets are
* always emptied and destroyed on removal.
*
* In the event of ManagedObjectsBucket's `Custom::ManagedBucketObjects` custom resource having
* a persistent problem, you can unblock your stack by adding the following environment variable
* to the ObjectManagerFunction lambda function also inside the stack:
*
* Key: `SKIP`, Value: `true`
*/
export class ManagedObjectsBucket extends s3.Bucket {
/** @hidden */
#inlineBucketObjects: Array<InlineBucketObject>
/** @hidden */
#assets: Array<s3_assets.Asset>
/** @hidden */
#ObjectChangeActions: Array<ObjectChangeAction>
/** @hidden */
#handlerRole: iam.Role
constructor(scope: Construct, id: string, props: ManagedObjectsBucketProps) {
super(scope, id, {
removalPolicy: cdk.RemovalPolicy.DESTROY,
...props
})
this.#inlineBucketObjects = []
this.#assets = []
this.#ObjectChangeActions = []
const codePackagePath = path.join(__dirname, "..", "..", "handler")
if (!readdirSync(codePackagePath).includes("node_modules")) {
execSync("npm install", { cwd: codePackagePath })
}
this.#handlerRole = new iam.Role(this, "ObjectManagerRole", {
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com")
})
this.#handlerRole.addToPolicy(new iam.PolicyStatement({
actions: [
"s3:ListBucket",
"s3:PutObject",
"s3:PutObjectAcl",
"s3:DeleteObject"
],
resources: [this.bucketArn, `${this.bucketArn}/*`]
}))
this.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.StarPrincipal()],
effect: iam.Effect.DENY,
actions: ["s3:PutObject", "s3:DeleteObject"],
resources: [`${this.bucketArn}/*`],
conditions: {
StringNotLike: {
"aws:userId": `${this.#handlerRole.roleId}:*`
}
}
}))
const handler = new lambda.Function(this, "ObjectManagerFunction", {
runtime: lambda.Runtime.NODEJS_24_X,
role: this.#handlerRole,
code: lambda.Code.fromAsset(codePackagePath),
handler: "index.handler",
timeout: cdk.Duration.seconds(900),
ephemeralStorageSize: cdk.Size.mebibytes(10240),
logGroup: props.objectManagerLogGroup
})
new cdk.CustomResource(this, "Objects", {
resourceType: "Custom::ManagedBucketObjects",
serviceToken: handler.functionArn,
properties: {
props: cdk.Lazy.any({
produce: () => ({
bucketUrl: `s3://${this.bucketName}`,
assets: this.#assets.map(asset => ({
hash: asset.assetHash,
s3BucketName: asset.s3BucketName,
s3ObjectKey: asset.s3ObjectKey
})),
objects: this.#inlineBucketObjects,
invalidationActions: this.#ObjectChangeActions
.filter(action => (action as CloudFrontInvalidationObjectChangeAction).distribution !== undefined)
.map(action => ({
distributionId: (action as CloudFrontInvalidationObjectChangeAction).distribution.distributionId,
waitForCompletion: (action as CloudFrontInvalidationObjectChangeAction).waitForCompletion
}))
})
})
}
})
}
/**
* Add an object to the bucket based on a given key and body. Deploy-time values from the CDK
* like resource ARNs can be used here.
*/
addObject(props: {
/** S3 object key for the object. */
key: string,
/** Content to be stored within the S3 object. */
body: string
}) {
if (this.#inlineBucketObjects.find(x => x.key === props.key)) {
throw new Error(`Cannot add object with duplicate key ${props.key} to ${this.node.id}.`)
}
this.#inlineBucketObjects.push(props)
}
/** Add objects to the bucket based on an [Asset](
* https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_assets-readme.html).
* For example:
*
* `bucket.addObjectsFromAsset({ asset: new Asset(this, "MyAsset", { path: "./my-local-files" }) })`
*/
addObjectsFromAsset(props: {
/** The [Asset](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_assets-readme.html
* ) to be added to the bucket. */
asset: s3_assets.Asset
}) {
if (this.#assets.find(x => x.assetHash === props.asset.assetHash)) {
throw new Error(`Cannot add objects from asset ${props.asset.assetHash} to ${this.node.id} twice.`)
}
this.#assets.push(props.asset)
this.#handlerRole.addToPolicy(new iam.PolicyStatement({
actions: ["s3:GetObject"],
resources: [`${props.asset.bucket.bucketArn}/*`]
}))
}
/** Add an action to be performed when objects in the bucket are changed. */
addObjectChangeAction(action: ObjectChangeAction) {
this.#ObjectChangeActions.push(action)
if ((action as CloudFrontInvalidationObjectChangeAction).distribution !== undefined) {
this.#handlerRole.addToPolicy(new iam.PolicyStatement({
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
resources: [getDistributionArn((action as CloudFrontInvalidationObjectChangeAction).distribution)]
}))
}
}
}
/** @hidden */
function getDistributionArn(distribution: cloudfront.IDistribution): string {
return `arn:aws:cloudfront::${distribution.stack.account}:distribution/${distribution.distributionId}`
}