|
1 | 1 | import { createHash } from 'node:crypto'; |
2 | | -import { Construct } from 'constructs'; |
| 2 | +import { Construct, IDependable } from 'constructs'; |
3 | 3 | import { CfnOutput, Duration, Fn, Stack } from 'aws-cdk-lib'; |
4 | 4 | import * as iam from 'aws-cdk-lib/aws-iam'; |
5 | 5 | import { |
@@ -33,6 +33,7 @@ import { |
33 | 33 | S3BucketOrigin, |
34 | 34 | } from 'aws-cdk-lib/aws-cloudfront-origins'; |
35 | 35 | import { IBucket } from 'aws-cdk-lib/aws-s3'; |
| 36 | +import { BucketDeployment } from 'aws-cdk-lib/aws-s3-deployment'; |
36 | 37 | import { |
37 | 38 | CfnPermission, |
38 | 39 | IFunction, |
@@ -190,6 +191,24 @@ export class CdnConstruct extends Construct { |
190 | 191 | readonly distributionUrl: string; |
191 | 192 | readonly errorPageHtml: string; |
192 | 193 |
|
| 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 | + |
193 | 212 | /** |
194 | 213 | * Creates the CDN distribution with routes mapped to origins. |
195 | 214 | */ |
@@ -276,6 +295,10 @@ export class CdnConstruct extends Construct { |
276 | 295 | manifest.basePath, |
277 | 296 | { spaFallback: isSpaFallback, wwwRedirect: props.wwwRedirect }, |
278 | 297 | ); |
| 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); |
279 | 302 |
|
280 | 303 | // ---- Skew protection viewer-response function ---- |
281 | 304 | const viewerResponseFunction = this.createViewerResponseFunction( |
@@ -752,6 +775,10 @@ export class CdnConstruct extends Construct { |
752 | 775 | forwardedHostFunction ?? |
753 | 776 | viewerRequestFunction, |
754 | 777 | ); |
| 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); |
755 | 782 | const prefixedStaticBehavior: BehaviorOptions = { |
756 | 783 | origin: s3Origin, |
757 | 784 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, |
@@ -1172,6 +1199,66 @@ export class CdnConstruct extends Construct { |
1172 | 1199 | description: 'Custom domain name for the hosted site', |
1173 | 1200 | }); |
1174 | 1201 | } |
| 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; |
1175 | 1262 | } |
1176 | 1263 |
|
1177 | 1264 | /** |
|
0 commit comments