-
Notifications
You must be signed in to change notification settings - Fork 47
fix(hosting): place CloudFront 5xx alarm in us-east-1 (#481) #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
58312ac
e8a3199
32b695e
c0b3ab8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| "@aws-blocks/hosting": minor | ||
| "@aws-blocks/core": patch | ||
| --- | ||
|
|
||
| Fix off-region CloudFront alarm placement (#481). | ||
|
|
||
| `HostingConstruct` now correctly handles the AWS constraint that | ||
| CloudFront metrics only publish in `us-east-1`: when the hosting stack | ||
| is deployed to a non-`us-east-1` region the CloudFront 5xx alarm is | ||
| placed in a synthesized `<stackName>-CfMonitoring` us-east-1 stack that | ||
| owns its own encrypted SNS topic and exposes a `MonitoringTopicArnUsEast1` | ||
| CloudFormation output for operator subscriptions. | ||
|
|
||
| Off-region deployments now defer the CloudFront 5xx alarm to a synthesized | ||
| `<stackName>-CfMonitoring` us-east-1 stack (own SNS topic + | ||
| MonitoringTopicArnUsEast1 output); this requires env:{account,region} to | ||
| be set off-region. Set monitoring.cloudFrontAlarm:'skip' to opt out (emits | ||
| a warning). | ||
|
|
||
| `@aws-blocks/core` gains the `cloudFrontAlarm` property on | ||
| `HostingMonitoringOptions` to surface the new `'skip'` | `'usEast1Stack'` | ||
| choice at the L3 config layer. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -382,6 +382,21 @@ export interface HostingProps { | |
| enabled?: boolean; | ||
| /** ARN of an existing SNS topic to send alarm actions to. */ | ||
| snsTopicArn?: string; | ||
| /** | ||
| * How to handle the CloudFront 5xx alarm when the app is deployed | ||
| * outside us-east-1. AWS/CloudFront metrics only exist in us-east-1 | ||
| * and a CloudWatch alarm can't watch a metric cross-region, so the | ||
| * alarm cannot live in an off-region stack. | ||
| * - `'usEast1Stack'`: place the alarm in a dedicated us-east-1 | ||
| * support stack (`<stackName>-CfMonitoring`) with its own SNS | ||
| * topic, surfaced as the `MonitoringTopicArnUsEast1` output. | ||
| * Requires `env: { account, region }` on the stack. | ||
| * - `'skip'`: omit the CloudFront alarm off-region (emit a warning). | ||
| * Ignored when the stack is already in us-east-1. | ||
| * | ||
| * When omitted, inherits the underlying default (`'usEast1Stack'`). | ||
| */ | ||
| cloudFrontAlarm?: 'usEast1Stack' | 'skip'; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -684,7 +699,14 @@ export class Hosting extends Construct { | |
| logging: props.logging, | ||
| buildCache: props.buildCache, | ||
| errorPages: skipPropsErrorPages ? undefined : props.errorPages, | ||
| monitoring: props.monitoring, | ||
| monitoring: props.monitoring | ||
| ? { | ||
| ...props.monitoring, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: |
||
| // cloudFrontAlarm flows through the spread as-is: undefined inherits | ||
| // the L3 default ('usEast1Stack'). To change the Blocks-app default | ||
| // to 'skip' later, set it explicitly here. | ||
| } | ||
| : undefined, | ||
| skewProtection: props.skewProtection, | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * HostingConstruct — CloudFront alarm region wiring (issue #481). | ||
| * | ||
| * AWS/CloudFront metrics only publish in us-east-1 and a CloudWatch | ||
| * alarm can only evaluate a metric in its own region. These tests cover | ||
| * the two-topic fix: off-region, the CloudFront 5xx alarm is placed in a | ||
| * hosting-owned us-east-1 support stack with its own SNS topic; in | ||
| * us-east-1 the behavior is unchanged (single stack, alarm local). | ||
| */ | ||
| import { afterEach, describe, it } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import { App, Stack } from 'aws-cdk-lib'; | ||
| import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; | ||
| import { HostingConstruct } from './hosting_construct.js'; | ||
| import type { DeployManifest } from '../manifest/types.js'; | ||
| import { HostingError } from '../hosting_error.js'; | ||
|
|
||
| let tmpDir: string; | ||
|
|
||
| const createStaticDir = (): string => { | ||
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hosting-cfregion-')); | ||
| fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html></html>'); | ||
| return tmpDir; | ||
| }; | ||
|
|
||
| const spaManifest = (staticDir: string): DeployManifest => ({ | ||
| version: 1, | ||
| compute: {}, | ||
| staticAssets: { directory: staticDir }, | ||
| routes: [{ pattern: '/*', target: 'static' }], | ||
| buildId: 'cfregion-test-1', | ||
| }); | ||
|
|
||
| const CF_ALARM = Match.objectLike({ Namespace: 'AWS/CloudFront' }); | ||
|
|
||
| void describe('HostingConstruct — CloudFront alarm region (#481)', () => { | ||
| afterEach(() => { | ||
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| // ---- (iv) In-region: unchanged single-stack behavior ---- | ||
| void it('creates the CloudFront alarm locally in a single stack when region is us-east-1', () => { | ||
| const staticDir = createStaticDir(); | ||
| const app = new App(); | ||
| const stack = new Stack(app, 'TestStack', { | ||
| env: { account: '123456789012', region: 'us-east-1' }, | ||
| }); | ||
| new HostingConstruct(stack, 'Hosting', { manifest: spaManifest(staticDir) }); | ||
|
|
||
| const template = Template.fromStack(stack); | ||
| // Alarm lives in this (us-east-1) stack. | ||
| template.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 1); | ||
| // No second stack was synthesized. | ||
| assert.strictEqual( | ||
| app.node.tryFindChild('TestStack-CfMonitoring'), | ||
| undefined, | ||
| ); | ||
| }); | ||
|
|
||
| // ---- (i) Off-region: two stacks, CF alarm in us-east-1 stack ---- | ||
| void it('places the CloudFront alarm in a us-east-1 support stack when off-region', () => { | ||
| const staticDir = createStaticDir(); | ||
| const app = new App(); | ||
| const stack = new Stack(app, 'TestStack', { | ||
| env: { account: '123456789012', region: 'ap-northeast-1' }, | ||
| }); | ||
| new HostingConstruct(stack, 'Hosting', { manifest: spaManifest(staticDir) }); | ||
|
|
||
| // The regional stack has NO CloudFront alarm... | ||
| const regional = Template.fromStack(stack); | ||
| regional.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 0); | ||
|
|
||
| // A sibling us-east-1 support stack exists and holds the alarm. | ||
| const support = app.node.tryFindChild('TestStack-CfMonitoring') as Stack; | ||
| assert.ok(support, 'expected a TestStack-CfMonitoring support stack'); | ||
| assert.strictEqual(support.region, 'us-east-1'); | ||
|
|
||
| const supportTemplate = Template.fromStack(support); | ||
| supportTemplate.resourcePropertiesCountIs( | ||
| 'AWS::CloudWatch::Alarm', | ||
| Match.objectLike({ | ||
| Namespace: 'AWS/CloudFront', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This asserts only Namespace/MetricName/Threshold/TreatMissingData. Since the point is that the re-homed alarm is identical to the regional one, please also assert |
||
| MetricName: '5xxErrorRate', | ||
| Threshold: 5, | ||
| TreatMissingData: 'notBreaching', | ||
| }), | ||
| 1, | ||
| ); | ||
| // Its own SNS topic (two-topic design). No forwarder subscription — | ||
| // consolidation is via two topics, not runtime message forwarding. | ||
| supportTemplate.resourceCountIs('AWS::SNS::Topic', 1); | ||
| supportTemplate.resourceCountIs('AWS::SNS::Subscription', 0); | ||
| // The us-east-1 topic ARN is surfaced as an output of the support stack. | ||
| supportTemplate.hasOutput( | ||
| '*', | ||
| Match.objectLike({ Description: Match.stringLikeRegexp('us-east-1') }), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ); | ||
| }); | ||
|
|
||
| // ---- (iii) 'skip' mode: warning, no second stack ---- | ||
| void it("emits a warning and creates no support stack when cloudFrontAlarm is 'skip'", () => { | ||
| const staticDir = createStaticDir(); | ||
| const app = new App(); | ||
| const stack = new Stack(app, 'TestStack', { | ||
| env: { account: '123456789012', region: 'ap-northeast-1' }, | ||
| }); | ||
| new HostingConstruct(stack, 'Hosting', { | ||
| manifest: spaManifest(staticDir), | ||
| monitoring: { cloudFrontAlarm: 'skip' }, | ||
| }); | ||
|
|
||
| const template = Template.fromStack(stack); | ||
| template.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 0); | ||
| assert.strictEqual( | ||
| app.node.tryFindChild('TestStack-CfMonitoring'), | ||
| undefined, | ||
| ); | ||
| Annotations.fromStack(stack).hasWarning( | ||
| '*', | ||
| Match.stringLikeRegexp('CloudFront 5xx alarm skipped'), | ||
| ); | ||
| }); | ||
|
|
||
| // ---- (ii) Off-region without a concrete account → throws ---- | ||
| void it('throws MonitoringEnvRequiredError off-region when the account is unresolved', () => { | ||
| const staticDir = createStaticDir(); | ||
| const app = new App(); | ||
| // region resolved (off-region) but account left unresolved. | ||
| const stack = new Stack(app, 'TestStack', { | ||
| env: { region: 'ap-northeast-1' }, | ||
| }); | ||
| assert.throws( | ||
| () => | ||
| new HostingConstruct(stack, 'Hosting', { | ||
| manifest: spaManifest(staticDir), | ||
| }), | ||
| (err: unknown) => | ||
| err instanceof HostingError && | ||
| err.code === 'MonitoringEnvRequiredError', | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,8 @@ import { | |
| RemovalPolicy, | ||
| Size, | ||
| Stack, | ||
| Stage, | ||
| Token, | ||
| } from 'aws-cdk-lib'; | ||
| import type { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; | ||
| import { | ||
|
|
@@ -52,6 +54,7 @@ import { CdnConstruct } from './cdn_construct.js'; | |
| import { ComputeConstruct } from './compute_construct.js'; | ||
| import { DnsConstruct } from './dns_construct.js'; | ||
| import { MonitoringConstruct } from './monitoring_construct.js'; | ||
| import { UsEast1MonitoringStack } from './us_east_1_monitoring_stack.js'; | ||
| import { DEFAULT_NODE_RUNTIME } from './node_runtime.js'; | ||
| import type { QuotaOverrides } from './quota_budget.js'; | ||
| import { createSecurityHeadersPolicy } from './security_headers.js'; | ||
|
|
@@ -320,6 +323,17 @@ export type HostingConstructProps = { | |
| /** @default true */ | ||
| enabled?: boolean; | ||
| snsTopicArn?: string; | ||
| /** | ||
| * How to handle the CloudFront 5xx alarm when this stack is not in | ||
| * us-east-1. CloudFront metrics only exist in us-east-1 and an alarm | ||
| * can't watch a metric cross-region (issue #481). | ||
| * - 'usEast1Stack' (default): place the alarm in a hosting-owned | ||
| * us-east-1 support stack (requires env: { account, region }). | ||
| * - 'skip': omit the CloudFront alarm off-region (emit a warning). | ||
| * Ignored when the stack is already in us-east-1. | ||
| * @default 'usEast1Stack' | ||
| */ | ||
| cloudFrontAlarm?: 'usEast1Stack' | 'skip'; | ||
| }; | ||
| /** | ||
| * Cookie-based skew protection. | ||
|
|
@@ -1150,6 +1164,15 @@ export class HostingConstruct extends Construct { | |
| : undefined; | ||
| const ssrFn = ssrComputeName ? this.computeFunctions.get(ssrComputeName) : undefined; | ||
| const imgFn = this.computeFunctions.get('image-optimization'); | ||
|
|
||
| // CloudFront metrics only exist in us-east-1 and an alarm can't | ||
| // watch a metric cross-region (issue #481). Off-region, defer the | ||
| // CloudFront alarm to a dedicated us-east-1 support stack. | ||
| const hostingStack = Stack.of(this); | ||
| const region = hostingStack.region; | ||
| const regionResolved = !Token.isUnresolved(region); | ||
| const offRegion = regionResolved && region !== 'us-east-1'; | ||
|
|
||
| const monitoring = new MonitoringConstruct(this, 'Monitoring', { | ||
| enabled: true, | ||
| snsTopic: userTopic, | ||
|
|
@@ -1159,6 +1182,7 @@ export class HostingConstruct extends Construct { | |
| ssrFunction: ssrFn instanceof LambdaFunction ? ssrFn : undefined, | ||
| imageFunction: imgFn instanceof LambdaFunction ? imgFn : undefined, | ||
| revalidationDlq: this.revalidationDlq, | ||
| createCloudFrontAlarmLocally: !offRegion, | ||
| }); | ||
| this.monitoringTopic = monitoring.topic; | ||
| if (monitoring.topic) { | ||
|
|
@@ -1167,6 +1191,63 @@ export class HostingConstruct extends Construct { | |
| description: 'SNS topic for hosting alarms. Subscribe an email/Slack/PagerDuty endpoint here.', | ||
| }); | ||
| } | ||
|
|
||
| // Off-region CloudFront alarm handling. | ||
| if (this.distribution && monitoring.cloudFrontAlarmDeferred) { | ||
| const cfAlarmMode = props.monitoring?.cloudFrontAlarm ?? 'usEast1Stack'; | ||
| if (cfAlarmMode === 'skip') { | ||
| Annotations.of(this).addWarningV2( | ||
| '@aws-blocks/hosting:CloudFrontAlarmSkipped', | ||
| `CloudFront 5xx alarm skipped: this stack is in ${region}, but ` + | ||
| `AWS/CloudFront metrics only exist in us-east-1 and an alarm ` + | ||
| `cannot watch a metric cross-region. Set ` + | ||
| `monitoring.cloudFrontAlarm: 'usEast1Stack' (requires ` + | ||
| `env: { account, region }) for real CloudFront 5xx coverage.`, | ||
| ); | ||
| } else if (Token.isUnresolved(hostingStack.account)) { | ||
| // A cross-region stack needs a concrete account. Fail loud | ||
| // rather than silently drop CloudFront monitoring. | ||
| throw new HostingError('MonitoringEnvRequiredError', { | ||
| message: | ||
| `monitoring.cloudFrontAlarm: 'usEast1Stack' requires an explicit ` + | ||
| `env: { account, region } on the stack (region '${region}'), ` + | ||
| `because the CloudFront alarm must be placed in a separate ` + | ||
| `us-east-1 stack.`, | ||
| resolution: | ||
| `Add env: { account, region } to the Stack, or set ` + | ||
| `monitoring.cloudFrontAlarm: 'skip' to omit the CloudFront alarm.`, | ||
| }); | ||
| } else { | ||
| // Belt-and-suspenders: only reachable for a Hosting construct with no enclosing App/Stage | ||
| // (CDK's App extends Stage, so Stage.of(this) resolves in normal use). Fail loud rather | ||
| // than synthesize a mis-scoped us-east-1 support stack. | ||
| const stage = Stage.of(this); | ||
| if (!stage) { | ||
| throw new HostingError('MonitoringStageRequiredError', { | ||
| message: | ||
| `Cannot create the us-east-1 CloudFront monitoring stack: no ` + | ||
| `enclosing App/Stage was found for this construct.`, | ||
| resolution: | ||
| `Instantiate hosting within a CDK App (or Stage), or set ` + | ||
| `monitoring.cloudFrontAlarm: 'skip'.`, | ||
| }); | ||
| } | ||
| // The us-east-1 CloudFront alarm must reference the regional | ||
| // distribution's id; CDK bridges that with its standard | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment ('CDK bridges that with its standard cross-region export reader/writer custom resources ... automatically') is correct, but it contradicts the |
||
| // cross-region export reader/writer custom resources (added to | ||
| // both stacks automatically). The topic ARN is surfaced as an | ||
| // output OF the support stack (MonitoringTopicArnUsEast1) — not | ||
| // re-output here, to keep the wiring one-directional. | ||
| new UsEast1MonitoringStack( | ||
| stage, | ||
| `${hostingStack.stackName}-CfMonitoring`, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| { | ||
| env: { account: hostingStack.account, region: 'us-east-1' }, | ||
| distributionId: this.distribution.distributionId, | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // ---- 9a. OPEN_NEXT_ORIGIN env var for URL construction ---- | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two things: (1) the off-region explanation is repeated in two adjacent paragraphs — collapse to one. (2) Please state explicitly that this is a breaking change (new default synthesizes a second stack off-region and throws
MonitoringEnvRequiredErrorfor env-agnostic off-region stacks) so it reads that way in the changelog. Theminorbump is the right label for a breaking change at 0.x.