Skip to content

Commit 061a0b2

Browse files
fix(hosting): atomic redeploys, eliminate deploy-window 403 (#24)
1 parent 835c425 commit 061a0b2

4 files changed

Lines changed: 542 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/hosting": patch
3+
---
4+
5+
fix(hosting): make redeploys atomic by uploading assets before the CloudFront build-id cutover, eliminating the 403 window for new visitors during deployment

packages/hosting/src/constructs/cdn_construct.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto';
2-
import { Construct } from 'constructs';
2+
import { Construct, IDependable } from 'constructs';
33
import { CfnOutput, Duration, Fn, Stack } from 'aws-cdk-lib';
44
import * as iam from 'aws-cdk-lib/aws-iam';
55
import {
@@ -33,6 +33,7 @@ import {
3333
S3BucketOrigin,
3434
} from 'aws-cdk-lib/aws-cloudfront-origins';
3535
import { IBucket } from 'aws-cdk-lib/aws-s3';
36+
import { BucketDeployment } from 'aws-cdk-lib/aws-s3-deployment';
3637
import {
3738
CfnPermission,
3839
IFunction,
@@ -190,6 +191,24 @@ export class CdnConstruct extends Construct {
190191
readonly distributionUrl: string;
191192
readonly errorPageHtml: string;
192193

194+
/**
195+
* CloudFront Functions that bake the deploy's buildId into the request
196+
* rewrite (`/builds/<buildId>/...`). Publishing one of these is the moment
197+
* the distribution starts routing new/cookieless traffic at the new build,
198+
* so they must not update until that build's assets have been uploaded.
199+
* See {@link addBuildAssetDependency}.
200+
*/
201+
private readonly buildIdFunctions: CloudFrontFunction[] = [];
202+
203+
/**
204+
* Count of asset deployments registered via {@link addBuildAssetDependency}.
205+
* The synth-time validation added in the constructor uses this to detect a
206+
* regression where the build-id cutover is left ungated (every build-id
207+
* function would publish before the new build's assets are uploaded,
208+
* re-opening the 403 deploy window).
209+
*/
210+
private buildAssetDependencyCount = 0;
211+
193212
/**
194213
* Creates the CDN distribution with routes mapped to origins.
195214
*/
@@ -276,6 +295,10 @@ export class CdnConstruct extends Construct {
276295
manifest.basePath,
277296
{ spaFallback: isSpaFallback, wwwRedirect: props.wwwRedirect },
278297
);
298+
// The viewer-request function rewrites every request to the new
299+
// build's `/builds/<buildId>/` prefix - gate its publish on the asset
300+
// uploads (see addBuildAssetDependency).
301+
this.buildIdFunctions.push(viewerRequestFunction);
279302

280303
// ---- Skew protection viewer-response function ----
281304
const viewerResponseFunction = this.createViewerResponseFunction(
@@ -752,6 +775,10 @@ export class CdnConstruct extends Construct {
752775
forwardedHostFunction ??
753776
viewerRequestFunction,
754777
);
778+
// Also bakes in the buildId (`/builds/<buildId>/`), so it must wait
779+
// for the asset uploads before publishing - same as the viewer-request
780+
// function above.
781+
this.buildIdFunctions.push(stripFunction);
755782
const prefixedStaticBehavior: BehaviorOptions = {
756783
origin: s3Origin,
757784
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
@@ -1172,6 +1199,66 @@ export class CdnConstruct extends Construct {
11721199
description: 'Custom domain name for the hosted site',
11731200
});
11741201
}
1202+
1203+
// ---- Self-enforcing atomic-deploy guard ----
1204+
// The build-id CloudFront functions rewrite every request to
1205+
// `/builds/<buildId>/...`. If they publish before that build's assets are
1206+
// uploaded to the OAC-protected bucket, new/cookieless visitors get 403
1207+
// for the whole deploy window. `addBuildAssetDependency` wires each asset
1208+
// BucketDeployment as a dependency so CloudFormation uploads first. This
1209+
// validation fails synth if build-id functions exist alongside asset
1210+
// deployments but NONE were registered - i.e. the wiring loop in the
1211+
// hosting construct was removed/broken, silently re-opening the 403
1212+
// window. It runs at synth, after all `addBuildAssetDependency` calls.
1213+
this.node.addValidation({
1214+
validate: (): string[] => {
1215+
// No build-id functions -> nothing to gate.
1216+
if (this.buildIdFunctions.length === 0) return [];
1217+
// At least one asset deployment was wired -> invariant holds.
1218+
if (this.buildAssetDependencyCount > 0) return [];
1219+
// Nothing was wired. Only fail if asset BucketDeployments actually
1220+
// exist in this stack; a standalone CdnConstruct with no assets (or a
1221+
// hypothetical asset-less deploy) is legitimate and must not
1222+
// false-positive.
1223+
const hasAssetDeployments = Stack.of(this)
1224+
.node.findAll()
1225+
.some((c) => c instanceof BucketDeployment);
1226+
if (!hasAssetDeployments) return [];
1227+
return [
1228+
`CdnConstruct '${this.node.path}' has ${this.buildIdFunctions.length} ` +
1229+
'build-id CloudFront function(s) that rewrite requests to ' +
1230+
"'/builds/<buildId>/...', but no asset BucketDeployment was " +
1231+
'registered via addBuildAssetDependency(). The build-id cutover ' +
1232+
'would publish before the new build assets are uploaded, ' +
1233+
'returning 403 Access Denied to new/cookieless visitors for the ' +
1234+
'entire deploy window. An asset BucketDeployment was likely added ' +
1235+
'without calling cdn.addBuildAssetDependency(deployment).',
1236+
];
1237+
},
1238+
});
1239+
}
1240+
1241+
/**
1242+
* Register a dependency that must finish before the build-id cutover.
1243+
*
1244+
* The viewer-request (and Next.js assetPrefix-strip) CloudFront Functions
1245+
* bake the deploy's buildId into the request rewrite, sending traffic to
1246+
* `/builds/<buildId>/...` in the OAC-protected S3 bucket. If those
1247+
* functions publish before the new build's assets land at that prefix,
1248+
* new/cookieless visitors get 403 Access Denied for the duration of the
1249+
* deploy window (returning visitors with a `__dpl` skew cookie keep hitting
1250+
* the previous build and are unaffected).
1251+
*
1252+
* The hosting construct calls this with every asset `BucketDeployment` for
1253+
* the new build, so CloudFormation uploads the assets first and only then
1254+
* publishes the functions (and the distribution that references them).
1255+
* This makes redeploys atomic from a new visitor's perspective.
1256+
*/
1257+
addBuildAssetDependency(dependency: IDependable): void {
1258+
for (const fn of this.buildIdFunctions) {
1259+
fn.node.addDependency(dependency);
1260+
}
1261+
this.buildAssetDependencyCount += 1;
11751262
}
11761263

11771264
/**

0 commit comments

Comments
 (0)