Skip to content

Commit a60e304

Browse files
committed
feat(route53): add failover routing policy support
1 parent bf10d94 commit a60e304

File tree

2 files changed

+168
-3
lines changed

2 files changed

+168
-3
lines changed

packages/aws-cdk-lib/aws-route53/lib/record-set.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,24 @@ export enum RecordType {
166166
TXT = 'TXT',
167167
}
168168

169+
/**
170+
* The failvoer policy.
171+
* @see https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-failover.html
172+
*/
173+
export enum Failover {
174+
/**
175+
* The primary resource record set determines how Route 53 responds to DNS queries when
176+
* the primary resource is healthy.
177+
*/
178+
PRIMARY = 'PRIMARY',
179+
180+
/**
181+
* The secondary resource record set determines how Route 53 responds to DNS queries when
182+
* the primary resource is unhealthy.
183+
*/
184+
SECONDARY = 'SECONDARY',
185+
}
186+
169187
/**
170188
* Options for a RecordSet.
171189
*/
@@ -289,6 +307,20 @@ export interface RecordSetOptions {
289307
* @default - No CIDR routing configured
290308
*/
291309
readonly cidrRoutingConfig?: CidrRoutingConfig;
310+
311+
/**
312+
* Failover configuration for the record set.
313+
*
314+
* To configure failover, you add the Failover element to two resource record sets.
315+
* For one resource record set, you specify PRIMARY as the value for Failover;
316+
* for the other resource record set, you specify SECONDARY.
317+
*
318+
* You must also include the HealthCheckId element for PRIMARY configurations.
319+
*
320+
* @default - No failover configuration
321+
* @see https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-failover.html
322+
*/
323+
readonly failover?: Failover;
292324
}
293325

294326
/**
@@ -353,6 +385,7 @@ export class RecordSet extends Resource implements IRecordSet {
353385
private readonly weight?: number;
354386
private readonly region?: string;
355387
private readonly multiValueAnswer?: boolean;
388+
private readonly failover?: Failover;
356389

357390
constructor(scope: Construct, id: string, props: RecordSetProps) {
358391
super(scope, id);
@@ -372,22 +405,36 @@ export class RecordSet extends Resource implements IRecordSet {
372405
if (props.multiValueAnswer && props.target.aliasTarget) {
373406
throw new ValidationError('multiValueAnswer cannot be specified for alias record', this);
374407
}
408+
if (props.failover && props.multiValueAnswer) {
409+
throw new ValidationError('Cannot use both failover and multiValueAnswer routing policies', this);
410+
}
411+
if (props.failover === Failover.PRIMARY && !props.healthCheck) {
412+
Annotations.of(this).addWarningV2('@aws-cdk/aws-route53:primaryFailoverHealthCheck', 'PRIMARY failover record sets should include a health check for proper failover behavior');
413+
}
414+
if (props.failover && props.target.aliasTarget) {
415+
const aliasTargetConfig = props.target.aliasTarget.bind(this, props.zone);
416+
if (aliasTargetConfig && aliasTargetConfig.evaluateTargetHealth !== true) {
417+
Annotations.of(this).addWarningV2('@aws-cdk/aws-route53:failoverAliasEvaluateTargetHealth', 'Failover alias record sets should include EvaluateTargetHealth = true for proper failover behavior.');
418+
}
419+
}
375420

376421
const nonSimpleRoutingPolicies = [
377422
props.geoLocation,
378423
props.region,
379424
props.weight,
380425
props.multiValueAnswer,
381426
props.cidrRoutingConfig,
427+
props.failover, // ADD THIS
382428
].filter((variable) => variable !== undefined).length;
383429
if (nonSimpleRoutingPolicies > 1) {
384-
throw new ValidationError('Only one of region, weight, multiValueAnswer, geoLocation or cidrRoutingConfig can be defined', this);
430+
throw new ValidationError('Only one of region, weight, multiValueAnswer, geoLocation, cidrRoutingConfig, or failover can be defined', this);
385431
}
386432

387433
this.geoLocation = props.geoLocation;
388434
this.weight = props.weight;
389435
this.region = props.region;
390436
this.multiValueAnswer = props.multiValueAnswer;
437+
this.failover = props.failover;
391438

392439
const ttl = props.target.aliasTarget ? undefined : ((props.ttl && props.ttl.toSeconds()) ?? 1800).toString();
393440
if (props.target.aliasTarget && props.ttl != undefined) {
@@ -415,6 +462,7 @@ export class RecordSet extends Resource implements IRecordSet {
415462
region: props.region,
416463
healthCheckId: props.healthCheck?.healthCheckId,
417464
cidrRoutingConfig: props.cidrRoutingConfig,
465+
failover: props.failover,
418466
});
419467

420468
this.domainName = recordSet.ref;
@@ -462,6 +510,11 @@ export class RecordSet extends Resource implements IRecordSet {
462510
}
463511

464512
private configureSetIdentifier(): string | undefined {
513+
if (this.failover) {
514+
const idPrefix = `FAILOVER_${this.failover}_ID_`;
515+
return this.createIdentifier(idPrefix);
516+
}
517+
465518
if (this.geoLocation) {
466519
let identifier = 'GEO';
467520
if (this.geoLocation.continentCode) {
@@ -1377,3 +1430,4 @@ export class CrossAccountZoneDelegationRecord extends Construct {
13771430
}
13781431
}
13791432
}
1433+

packages/aws-cdk-lib/aws-route53/test/record-set.test.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { testDeprecated } from '@aws-cdk/cdk-build-tools';
2-
import { Annotations, Template } from '../../assertions';
2+
import { Annotations, Template, Match } from '../../assertions';
33
import * as cloudfront from '../../aws-cloudfront';
44
import * as origins from '../../aws-cloudfront-origins';
55
import * as iam from '../../aws-iam';
@@ -1733,7 +1733,7 @@ describe('record set', () => {
17331733
target: route53.RecordTarget.fromValues('zzz'),
17341734
setIdentifier: 'uniqueId',
17351735
...props,
1736-
})).toThrow('Only one of region, weight, multiValueAnswer, geoLocation or cidrRoutingConfig can be defined');
1736+
})).toThrow('Only one of region, weight, multiValueAnswer, geoLocation, cidrRoutingConfig, or failover can be defined');
17371737
});
17381738

17391739
test('throw error for the definition of setIdentifier without weight, geoLocation or region', () => {
@@ -1803,4 +1803,115 @@ describe('record set', () => {
18031803
multiValueAnswer: true,
18041804
})).toThrow('multiValueAnswer cannot be specified for alias record');
18051805
});
1806+
1807+
test('A record with PRIMARY failover and health check', () => {
1808+
// GIVEN
1809+
const stack = new Stack();
1810+
const zone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'myzone' });
1811+
1812+
const healthCheck = new route53.HealthCheck(stack, 'HealthCheck', {
1813+
type: route53.HealthCheckType.HTTP,
1814+
fqdn: 'example.com',
1815+
});
1816+
1817+
// WHEN
1818+
new route53.ARecord(stack, 'PrimaryFailover', {
1819+
zone,
1820+
recordName: 'www',
1821+
target: route53.RecordTarget.fromIpAddresses('1.2.3.4'),
1822+
failover: route53.Failover.PRIMARY,
1823+
healthCheck,
1824+
});
1825+
1826+
// THEN
1827+
Template.fromStack(stack).hasResourceProperties('AWS::Route53::RecordSet', {
1828+
Failover: 'PRIMARY',
1829+
HealthCheckId: stack.resolve(healthCheck.healthCheckId),
1830+
SetIdentifier: 'FAILOVER_PRIMARY_ID_PrimaryFailover',
1831+
});
1832+
});
1833+
1834+
test('A record with SECONDARY failover and no health check', () => {
1835+
// GIVEN
1836+
const stack = new Stack();
1837+
const zone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'myzone' });
1838+
1839+
// WHEN
1840+
new route53.ARecord(stack, 'SecondaryFailover', {
1841+
zone,
1842+
recordName: 'backup',
1843+
target: route53.RecordTarget.fromIpAddresses('5.6.7.8'),
1844+
failover: route53.Failover.SECONDARY,
1845+
});
1846+
1847+
// THEN
1848+
Template.fromStack(stack).hasResourceProperties('AWS::Route53::RecordSet', {
1849+
Failover: 'SECONDARY',
1850+
SetIdentifier: 'FAILOVER_SECONDARY_ID_SecondaryFailover',
1851+
});
1852+
});
1853+
1854+
test('warns when PRIMARY failover has no health check', () => {
1855+
// GIVEN
1856+
const stack = new Stack();
1857+
const zone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'myzone' });
1858+
1859+
// WHEN
1860+
new route53.ARecord(stack, 'PrimaryFailover', {
1861+
zone,
1862+
recordName: 'www',
1863+
target: route53.RecordTarget.fromIpAddresses('1.2.3.4'),
1864+
failover: route53.Failover.PRIMARY,
1865+
});
1866+
1867+
// THEN
1868+
Annotations.fromStack(stack).hasWarning(
1869+
'*',
1870+
Match.stringLikeRegexp('PRIMARY failover record sets should include a health check'),
1871+
);
1872+
});
1873+
1874+
test('warns when failover alias target does not set EvaluateTargetHealth=true', () => {
1875+
// GIVEN
1876+
const stack = new Stack();
1877+
const zone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'myzone' });
1878+
1879+
const target: route53.IAliasRecordTarget = {
1880+
bind: () => ({
1881+
hostedZoneId: 'Z111',
1882+
dnsName: 'alias.example.com',
1883+
evaluateTargetHealth: false,
1884+
}),
1885+
};
1886+
1887+
// WHEN
1888+
new route53.ARecord(stack, 'AliasFailover', {
1889+
zone,
1890+
recordName: 'www',
1891+
target: route53.RecordTarget.fromAlias(target),
1892+
failover: route53.Failover.PRIMARY,
1893+
});
1894+
1895+
// THEN
1896+
Annotations.fromStack(stack).hasWarning(
1897+
'*',
1898+
Match.stringLikeRegexp('Failover alias record sets should include EvaluateTargetHealth'),
1899+
);
1900+
});
1901+
1902+
test('throws when failover is combined with another routing policy', () => {
1903+
// GIVEN
1904+
const stack = new Stack();
1905+
const zone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'myzone' });
1906+
1907+
// THEN
1908+
expect(() => new route53.ARecord(stack, 'InvalidFailover', {
1909+
zone,
1910+
recordName: 'www',
1911+
target: route53.RecordTarget.fromIpAddresses('1.2.3.4'),
1912+
failover: route53.Failover.PRIMARY,
1913+
weight: 10,
1914+
})).toThrow('Only one of region, weight, multiValueAnswer, geoLocation, cidrRoutingConfig, or failover can be defined');
1915+
});
1916+
18061917
});

0 commit comments

Comments
 (0)