Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/cf-alarm-us-east-1-off-region.md
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

Copy link
Copy Markdown
Contributor

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 MonitoringEnvRequiredError for env-agnostic off-region stacks) so it reads that way in the changelog. The minor bump is the right label for a breaking change at 0.x.

`<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.
24 changes: 23 additions & 1 deletion packages/core/src/hosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};

/**
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: { ...props.monitoring } is a functional no-op here — cloudFrontAlarm already flowed through the previous monitoring: props.monitoring. Fine to keep for the anchoring comment, but the @aws-blocks/core: patch changeset shouldn't imply core behavior changed; it's just surfacing the type.

// 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,
};

Expand Down
38 changes: 38 additions & 0 deletions packages/hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<stackName>-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
Expand Down
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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 dimensionsMap (Region: 'Global' + DistributionId), ComparisonOperator, Period, Statistic, and EvaluationPeriods to lock parity against future drift.

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') }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasOutput('*', { Description: /us-east-1/ }) matches on the description, but docs and operators depend on the logical id MonitoringTopicArnUsEast1. Assert hasOutput('MonitoringTopicArnUsEast1', ...) so a rename can't silently pass.

);
});

// ---- (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',
);
});
});
81 changes: 81 additions & 0 deletions packages/hosting/src/constructs/hosting_construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
RemovalPolicy,
Size,
Stack,
Stage,
Token,
} from 'aws-cdk-lib';
import type { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 distributionId prop doc in us_east_1_monitoring_stack.ts ('no cross-region reference'). Please reconcile the two so the same story is told in both places.

// 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`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${hostingStack.stackName}-CfMonitoring collides if two HostingConstructs are instantiated in the same stack (duplicate construct id under the same Stage). Since HostingConstruct is an embeddable L3, consider folding the construct's node id/addr into the support-stack id to keep it unique.

{
env: { account: hostingStack.account, region: 'us-east-1' },
distributionId: this.distribution.distributionId,
},
);
}
}
}

// ---- 9a. OPEN_NEXT_ORIGIN env var for URL construction ----
Expand Down
Loading
Loading