diff --git a/.changeset/cf-alarm-us-east-1-off-region.md b/.changeset/cf-alarm-us-east-1-off-region.md new file mode 100644 index 000000000..c7d7c7695 --- /dev/null +++ b/.changeset/cf-alarm-us-east-1-off-region.md @@ -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 `-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 +`-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. diff --git a/packages/core/src/hosting.ts b/packages/core/src/hosting.ts index f76302f25..8ee896072 100644 --- a/packages/core/src/hosting.ts +++ b/packages/core/src/hosting.ts @@ -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 (`-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, + // 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, }; diff --git a/packages/hosting/README.md b/packages/hosting/README.md index b990b7fea..dc61fb16c 100644 --- a/packages/hosting/README.md +++ b/packages/hosting/README.md @@ -335,6 +335,44 @@ phases around the deploy: (`app.example.com`) to the `DistributionDomainName` value. For an apex domain, use an ALIAS or ANAME record if your provider supports it. +## Off-region CloudFront alarm parity + +AWS CloudFront metrics only publish in `us-east-1`. A CloudWatch alarm +can only evaluate a metric in its own region, so the CloudFront 5xx +alarm cannot be placed in a non-`us-east-1` hosting stack. + +When `HostingConstruct` detects that the stack's region is **not** +`us-east-1` it automatically synthesizes a companion stack named +`-CfMonitoring` pinned to `us-east-1`. That stack: + +- creates its own encrypted SNS topic (the alarm action target), +- wires the CloudFront 5xx rate alarm to that topic, and +- emits a `MonitoringTopicArnUsEast1` CloudFormation output so operators + can subscribe their on-call pipeline to it. + +> **Requirement:** because the companion stack is region-pinned you must +> set `env: { account, region }` on the parent hosting stack when +> deploying outside `us-east-1`, exactly as you would for a WAF stack +> (see `waf_construct.ts`). An unresolved (`Token.isUnresolved`) region +> is left to the CDK to resolve at deploy time. + +To opt out — for example in a region where the CloudFront distribution +is managed separately — set `monitoring.cloudFrontAlarm: 'skip'`. The +construct emits a CDK warning and omits the alarm entirely. + +```ts +new HostingConstruct(this, 'Hosting', { + // ... + monitoring: { + cloudFrontAlarm: 'skip', // suppress the us-east-1 companion stack + }, +}); +``` + +The default value (`'usEast1Stack'`) creates the companion stack. In +`us-east-1` no companion stack is created — the alarm stays local. + + ## Development ```bash diff --git a/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts new file mode 100644 index 000000000..81ee6ac65 --- /dev/null +++ b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts @@ -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'), ''); + 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', + 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') }), + ); + }); + + // ---- (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', + ); + }); +}); diff --git a/packages/hosting/src/constructs/hosting_construct.ts b/packages/hosting/src/constructs/hosting_construct.ts index f54670863..1217189d5 100644 --- a/packages/hosting/src/constructs/hosting_construct.ts +++ b/packages/hosting/src/constructs/hosting_construct.ts @@ -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 + // 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`, + { + env: { account: hostingStack.account, region: 'us-east-1' }, + distributionId: this.distribution.distributionId, + }, + ); + } + } } // ---- 9a. OPEN_NEXT_ORIGIN env var for URL construction ---- diff --git a/packages/hosting/src/constructs/monitoring_construct.test.ts b/packages/hosting/src/constructs/monitoring_construct.test.ts index 8c03bb224..68d9c4f11 100644 --- a/packages/hosting/src/constructs/monitoring_construct.test.ts +++ b/packages/hosting/src/constructs/monitoring_construct.test.ts @@ -165,6 +165,49 @@ void describe('MonitoringConstruct', () => { // No alarm at all because no distribution / Lambda / DLQ given. template.resourceCountIs('AWS::CloudWatch::Alarm', 0); }); + + // Issue #481: off-region, the CloudFront alarm is deferred to a + // us-east-1 support stack by the parent instead of created here. + void it('is deferred (not created locally) when createCloudFrontAlarmLocally is false', () => { + const stack = createStack(); + const m = new MonitoringConstruct(stack, 'Monitoring', { + enabled: true, + distribution: newDistribution(stack), + createCloudFrontAlarmLocally: false, + }); + const template = Template.fromStack(stack); + + assert.strictEqual(m.cloudFrontAlarmDeferred, true); + // No AWS/CloudFront alarm was created in this (regional) stack. + template.resourcePropertiesCountIs( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ Namespace: 'AWS/CloudFront' }), + 0, + ); + }); + + void it('still creates the regional alarms (SSR/image/DLQ) when the CF alarm is deferred', () => { + const stack = createStack(); + const m = new MonitoringConstruct(stack, 'Monitoring', { + enabled: true, + distribution: newDistribution(stack), + ssrFunction: newLambda(stack, 'Ssr'), + imageFunction: newLambda(stack, 'Img'), + revalidationDlq: new Queue(stack, 'Dlq'), + createCloudFrontAlarmLocally: false, + }); + const template = Template.fromStack(stack); + + assert.strictEqual(m.cloudFrontAlarmDeferred, true); + // 4 regional alarms (2 SSR + 1 image + 1 DLQ); the CF alarm is deferred. + template.resourceCountIs('AWS::CloudWatch::Alarm', 4); + assert.strictEqual(m.alarms.length, 4); + template.resourcePropertiesCountIs( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ Namespace: 'AWS/CloudFront' }), + 0, + ); + }); }); void describe('SSR Lambda alarms', () => { diff --git a/packages/hosting/src/constructs/monitoring_construct.ts b/packages/hosting/src/constructs/monitoring_construct.ts index 9698afc47..b41646037 100644 --- a/packages/hosting/src/constructs/monitoring_construct.ts +++ b/packages/hosting/src/constructs/monitoring_construct.ts @@ -8,12 +8,12 @@ import { TreatMissingData, } from 'aws-cdk-lib/aws-cloudwatch'; import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions'; -import { Distribution } from 'aws-cdk-lib/aws-cloudfront'; +import type { Distribution } from 'aws-cdk-lib/aws-cloudfront'; import * as iam from 'aws-cdk-lib/aws-iam'; import { type IKey, Key } from 'aws-cdk-lib/aws-kms'; -import { Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; -import { Queue } from 'aws-cdk-lib/aws-sqs'; -import { ITopic, Topic } from 'aws-cdk-lib/aws-sns'; +import type { Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; +import type { Queue } from 'aws-cdk-lib/aws-sqs'; +import { type ITopic, Topic } from 'aws-cdk-lib/aws-sns'; /** * Default CloudWatch alarm wiring (P3.1 + P3.2). @@ -71,6 +71,18 @@ export type MonitoringConstructProps = { * @default 1 (alarm when >=1% of invocations error) */ ssrErrorRatePercent?: number; + /** + * When `true` (default) the CloudFront 5xx alarm is created in THIS + * (regional) construct. `AWS/CloudFront` metrics only publish in + * us-east-1 and a CloudWatch alarm can only evaluate a metric in its + * own region, so for an off-region hosting stack the parent sets this + * to `false` and instead places the alarm in a dedicated us-east-1 + * support stack (issue #481). When `false` and a `distribution` is + * supplied, the CF alarm is skipped here and `cloudFrontAlarmDeferred` + * is set to `true`. The regional alarms (SSR/image/DLQ) are unaffected. + * @default true + */ + createCloudFrontAlarmLocally?: boolean; }; /** @@ -94,7 +106,7 @@ export type MonitoringConstructProps = { * service-principal calls, and a hard `StringEquals` would * reintroduce the exact silent-deny this grant exists to prevent. */ -const createAlarmTopicKey = (scope: Construct): Key => { +export const createAlarmTopicKey = (scope: Construct): Key => { const key = new Key(scope, 'AlarmTopicKey', { description: 'Encrypts CloudWatch alarm notifications published to the hosting alarm topic.', @@ -137,6 +149,13 @@ export class MonitoringConstruct extends Construct { readonly encryptionKey?: IKey; /** All CloudWatch alarms created by this construct. */ readonly alarms: Alarm[] = []; + /** + * True when a `distribution` was supplied but the CloudFront 5xx + * alarm was NOT created here because `createCloudFrontAlarmLocally` + * was `false` (off-region). The parent must place the alarm in a + * us-east-1 support stack. See issue #481. + */ + readonly cloudFrontAlarmDeferred: boolean = false; /** * Wire the default alarm set to the user-supplied or auto-created @@ -159,7 +178,7 @@ export class MonitoringConstruct extends Construct { } const action = new SnsAction(this.topic); - if (props.distribution) { + if (props.distribution && (props.createCloudFrontAlarmLocally ?? true)) { const cf5xx = new Alarm(this, 'CloudFront5xxRate', { metric: new Metric({ namespace: 'AWS/CloudFront', @@ -182,6 +201,11 @@ export class MonitoringConstruct extends Construct { }); cf5xx.addAlarmAction(action); this.alarms.push(cf5xx); + } else if (props.distribution) { + // Off-region: the CloudFront metric only exists in us-east-1 and an + // alarm can't watch a metric cross-region, so defer the alarm to a + // us-east-1 support stack owned by the parent. See issue #481. + this.cloudFrontAlarmDeferred = true; } if (props.ssrFunction) { diff --git a/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts b/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts new file mode 100644 index 000000000..2cf7d35ec --- /dev/null +++ b/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts @@ -0,0 +1,46 @@ +/** + * UsEast1MonitoringStack — BYO-topic behavior (issue #481). + * + * The support stack normally creates its own encrypted us-east-1 topic, + * but a caller may supply an existing us-east-1 topic. When they do, no + * new topic/key is created and the alarm action targets the supplied + * topic. + */ +import { describe, it } from 'node:test'; +import { App, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { Topic } from 'aws-cdk-lib/aws-sns'; +import { UsEast1MonitoringStack } from './us_east_1_monitoring_stack.js'; + +void describe('UsEast1MonitoringStack — BYO topic', () => { + void it('reuses a supplied topic: no new topic or KMS key, alarm targets it', () => { + const app = new App(); + // An imported us-east-1 topic (BYO by ARN — no cross-stack export). + const arnStack = new Stack(app, 'ArnStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + const topicArn = 'arn:aws:sns:us-east-1:123456789012:byo-alarm-topic'; + const userTopic = Topic.fromTopicArn(arnStack, 'UserTopic', topicArn); + + const stack = new UsEast1MonitoringStack(app, 'CfMon', { + env: { account: '123456789012', region: 'us-east-1' }, + distributionId: 'E1234567890ABC', + snsTopic: userTopic, + }); + const template = Template.fromStack(stack); + + // The support stack created neither a topic nor an encryption key. + template.resourceCountIs('AWS::SNS::Topic', 0); + template.resourceCountIs('AWS::KMS::Key', 0); + + // The alarm exists and its action is the supplied (imported) topic ARN. + template.hasResourceProperties( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ + Namespace: 'AWS/CloudFront', + MetricName: '5xxErrorRate', + AlarmActions: [topicArn], + }), + ); + }); +}); diff --git a/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts new file mode 100644 index 000000000..a72a267d4 --- /dev/null +++ b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts @@ -0,0 +1,113 @@ +import type { Construct } from 'constructs'; +import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib'; +import { + Alarm, + ComparisonOperator, + Metric, + TreatMissingData, +} from 'aws-cdk-lib/aws-cloudwatch'; +import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions'; +import type { IKey } from 'aws-cdk-lib/aws-kms'; +import { type ITopic, Topic } from 'aws-cdk-lib/aws-sns'; +import { createAlarmTopicKey } from './monitoring_construct.js'; + +/** + * Props for {@link UsEast1MonitoringStack}. + */ +export type UsEast1MonitoringStackProps = StackProps & { + /** + * CloudFront distribution id to alarm on. Passed as a plain string so + * this us-east-1 stack takes no cross-region CDK reference on the + * regional hosting stack. + */ + distributionId: string; + /** + * BYO SNS topic for the alarm action. When omitted an encrypted topic + * is created in THIS (us-east-1) stack and exposed via `topic` for the + * operator to subscribe to (surfaced by the parent as the + * `MonitoringTopicArnUsEast1` output). Supply this only if you already + * have a us-east-1 topic — a regional topic cannot be used because a + * CloudWatch alarm's SNS action must target a topic in the alarm's own + * region. + */ + snsTopic?: ITopic; +}; + +/** + * Hosting-owned **us-east-1** support stack that holds the CloudFront + * 5xx alarm (issue #481). + * + * `AWS/CloudFront` metrics are published only in us-east-1, and a + * CloudWatch alarm can only evaluate a metric in its own region + * (confirmed by the CloudWatch docs — "Cross-Region functionality is + * not supported for alarms" — and rejected by aws-cdk-lib at synth). An + * off-region hosting stack therefore cannot host a working CloudFront + * alarm; the parent creates this stack next to it (same account, + * region pinned to us-east-1) so the alarm actually evaluates. + * + * Two-topic design: this stack owns its OWN us-east-1 alarm topic (no + * cross-region SNS plumbing, no forwarder Lambda). The operator + * subscribes to this topic's ARN — surfaced by the parent as + * `MonitoringTopicArnUsEast1` — in addition to the regional + * `MonitoringTopicArn`. + */ +export class UsEast1MonitoringStack extends Stack { + /** The us-east-1 topic the CloudFront alarm publishes to. */ + readonly topic: ITopic; + /** KMS key encrypting the auto-created topic (undefined for a BYO topic). */ + readonly encryptionKey?: IKey; + /** The CloudFront 5xx alarm. */ + readonly alarm: Alarm; + + constructor( + scope: Construct, + id: string, + props: UsEast1MonitoringStackProps, + ) { + super(scope, id, props); + + if (props.snsTopic) { + this.topic = props.snsTopic; + } else { + this.encryptionKey = createAlarmTopicKey(this); + this.topic = new Topic(this, 'AlarmTopic', { + masterKey: this.encryptionKey, + }); + } + + // Identical alarm config to the regional construct's original — just + // re-homed to us-east-1 where the metric actually exists. + this.alarm = new Alarm(this, 'CloudFront5xxRate', { + metric: new Metric({ + namespace: 'AWS/CloudFront', + metricName: '5xxErrorRate', + dimensionsMap: { + DistributionId: props.distributionId, + // CloudFront metrics live in us-east-1 regardless of stack. + Region: 'Global', + }, + period: Duration.minutes(5), + statistic: 'Average', + }), + threshold: 5, // percent + evaluationPeriods: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: TreatMissingData.NOT_BREACHING, + alarmDescription: + 'CloudFront is returning 5xx for >=5% of requests over 5 minutes.', + }); + this.alarm.addAlarmAction(new SnsAction(this.topic)); + + // Surface the us-east-1 topic ARN as an output OF THIS stack (not the + // regional one) so there is no cross-region reference — the operator + // subscribes to this in addition to the regional MonitoringTopicArn. + new CfnOutput(this, 'MonitoringTopicArnUsEast1', { + value: this.topic.topicArn, + description: + 'SNS topic (us-east-1) for the CloudFront 5xx alarm. Subscribe an ' + + 'endpoint here in addition to the regional MonitoringTopicArn — the ' + + 'CloudFront alarm lives in this us-east-1 stack because AWS/CloudFront ' + + 'metrics only exist in us-east-1.', + }); + } +} diff --git a/packages/hosting/src/types.ts b/packages/hosting/src/types.ts index 258433f93..37f235686 100644 --- a/packages/hosting/src/types.ts +++ b/packages/hosting/src/types.ts @@ -351,6 +351,29 @@ export type HostingProps = { * is created. */ snsTopicArn?: string; + /** + * How to handle the CloudFront 5xx alarm when the hosting stack is + * NOT in us-east-1. + * + * `AWS/CloudFront` metrics are published only in us-east-1, and a + * CloudWatch alarm can only evaluate a metric in its own region, so + * an off-region stack cannot host a working CloudFront alarm. + * + * - `'usEast1Stack'` (default): synthesize a small hosting-owned + * us-east-1 support stack that holds the alarm and its own SNS + * topic. The topic ARN is surfaced as the `MonitoringTopicArnUsEast1` + * CloudFormation output for the operator to subscribe to (in + * addition to the regional `MonitoringTopicArn`). This REQUIRES an + * explicit `env: { account, region }` on the stack — an + * env-agnostic stack cannot create a second-region stack. + * - `'skip'`: do not create the CloudFront alarm off-region; a synth + * warning is emitted instead. No second stack; works env-agnostic. + * + * Ignored when the stack is already in us-east-1 (the alarm is created + * locally in the single regional stack, unchanged). + * @default 'usEast1Stack' + */ + cloudFrontAlarm?: 'usEast1Stack' | 'skip'; }; /**