Skip to content

Commit

Permalink
feat(route53): replace existing record sets (#20416)
Browse files Browse the repository at this point in the history
Add a `deleteExisting` prop to `RecordSet` to delete an existing record
set before deploying the new one. This is useful if you want to minimize
downtime and avoid "manual" actions while deploying a stack with a
record set that already exists. This is typically the case for record
sets that are not already "owned" by CloudFormation or "owned" by
another stack or construct that is going to be deleted (migration).


----

### All Submissions:

* [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md)

### Adding new Unconventional Dependencies:

* [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md/#adding-new-unconventional-dependencies)

### New Features

* [x] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/master/INTEGRATION_TESTS.md)?
	* [x] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)?

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
jogold committed Jun 16, 2022
1 parent d66534a commit 2f92c35
Show file tree
Hide file tree
Showing 17 changed files with 1,128 additions and 9 deletions.
34 changes: 27 additions & 7 deletions packages/@aws-cdk/aws-route53/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ declare const myZone: route53.HostedZone;

new route53.NsRecord(this, 'NSRecord', {
zone: myZone,
recordName: 'foo',
values: [
recordName: 'foo',
values: [
'ns-1.awsdns.co.uk.',
'ns-2.awsdns.com.',
],
Expand Down Expand Up @@ -132,6 +132,26 @@ Constructs are available for A, AAAA, CAA, CNAME, MX, NS, SRV and TXT records.
Use the `CaaAmazonRecord` construct to easily restrict certificate authorities
allowed to issue certificates for a domain to Amazon only.

### Working with existing record sets

Use the `deleteExisting` prop to delete an existing record set before deploying the new one.
This is useful if you want to minimize downtime and avoid "manual" actions while deploying a
stack with a record set that already exists. This is typically the case for record sets that
are not already "owned" by CloudFormation or "owned" by another stack or construct that is
going to be deleted (migration).

```ts
declare const myZone: route53.HostedZone;

new route53.ARecord(this, 'ARecord', {
zone: myZone,
target: route53.RecordTarget.fromIpAddresses('1.2.3.4', '5.6.7.8'),
deleteExisting: true,
});
```

### Cross Account Zone Delegation

To add a NS record to a HostedZone in different account you can do the following:

In the account containing the parent hosted zone:
Expand Down Expand Up @@ -171,7 +191,7 @@ new route53.CrossAccountZoneDelegationRecord(this, 'delegate', {

## Imports

If you don't know the ID of the Hosted Zone to import, you can use the
If you don't know the ID of the Hosted Zone to import, you can use the
`HostedZone.fromLookup`:

```ts
Expand All @@ -181,14 +201,14 @@ route53.HostedZone.fromLookup(this, 'MyZone', {
```

`HostedZone.fromLookup` requires an environment to be configured. Check
out the [documentation](https://docs.aws.amazon.com/cdk/latest/guide/environments.html) for more documentation and examples. CDK
out the [documentation](https://docs.aws.amazon.com/cdk/latest/guide/environments.html) for more documentation and examples. CDK
automatically looks into your `~/.aws/config` file for the `[default]` profile.
If you want to specify a different account run `cdk deploy --profile [profile]`.

```text
new MyDevStack(app, 'dev', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
new MyDevStack(app, 'dev', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Route53 } from 'aws-sdk'; // eslint-disable-line import/no-extraneous-dependencies

interface ResourceProperties {
HostedZoneId: string;
RecordName: string;
RecordType: string;
}

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const resourceProps = event.ResourceProperties as unknown as ResourceProperties;

// Only delete the existing record when the new one gets created
if (event.RequestType !== 'Create') {
return;
}

const route53 = new Route53({ apiVersion: '2013-04-01' });

const listResourceRecordSets = await route53.listResourceRecordSets({
HostedZoneId: resourceProps.HostedZoneId,
StartRecordName: resourceProps.RecordName,
StartRecordType: resourceProps.RecordType,
}).promise();

const existingRecord = listResourceRecordSets.ResourceRecordSets
.find(r => r.Name === resourceProps.RecordName && r.Type === resourceProps.RecordType);

if (!existingRecord) {
// There is no existing record, we can safely return
return;
}

const changeResourceRecordSets = await route53.changeResourceRecordSets({
HostedZoneId: resourceProps.HostedZoneId,
ChangeBatch: {
Changes: [{
Action: 'DELETE',
ResourceRecordSet: {
Name: existingRecord.Name,
Type: existingRecord.Type,
// changeResourceRecordSets does not correctly handle undefined values
// https://github.com/aws/aws-sdk-js/issues/3506
...existingRecord.TTL ? { TTL: existingRecord.TTL } : {},
...existingRecord.AliasTarget ? { AliasTarget: existingRecord.AliasTarget } : {},
...existingRecord.ResourceRecords ? { ResourceRecords: existingRecord.ResourceRecords } : {},
},
}],
},
}).promise();

await route53.waitFor('resourceRecordSetsChanged', { Id: changeResourceRecordSets.ChangeInfo.Id }).promise();

return {
PhysicalResourceId: `${existingRecord.Name}-${existingRecord.Type}`,
};
}
50 changes: 49 additions & 1 deletion packages/@aws-cdk/aws-route53/lib/record-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CfnRecordSet } from './route53.generated';
import { determineFullyQualifiedDomainName } from './util';

const CROSS_ACCOUNT_ZONE_DELEGATION_RESOURCE_TYPE = 'Custom::CrossAccountZoneDelegation';
const DELETE_EXISTING_RECORD_SET_RESOURCE_TYPE = 'Custom::DeleteExistingRecordSet';

/**
* A record set
Expand Down Expand Up @@ -154,6 +155,17 @@ export interface RecordSetOptions {
* @default no comment
*/
readonly comment?: string;

/**
* Whether to delete the same record set in the hosted zone if it already exists.
*
* This allows to deploy a new record set while minimizing the downtime because the
* new record set will be created immediately after the existing one is deleted. It
* also avoids "manual" actions to delete existing record sets.
*
* @default false
*/
readonly deleteExisting?: boolean;
}

/**
Expand Down Expand Up @@ -217,9 +229,11 @@ export class RecordSet extends Resource implements IRecordSet {

const ttl = props.target.aliasTarget ? undefined : ((props.ttl && props.ttl.toSeconds()) ?? 1800).toString();

const recordName = determineFullyQualifiedDomainName(props.recordName || props.zone.zoneName, props.zone);

const recordSet = new CfnRecordSet(this, 'Resource', {
hostedZoneId: props.zone.hostedZoneId,
name: determineFullyQualifiedDomainName(props.recordName || props.zone.zoneName, props.zone),
name: recordName,
type: props.recordType,
resourceRecords: props.target.values,
aliasTarget: props.target.aliasTarget && props.target.aliasTarget.bind(this, props.zone),
Expand All @@ -228,6 +242,40 @@ export class RecordSet extends Resource implements IRecordSet {
});

this.domainName = recordSet.ref;

if (props.deleteExisting) {
// Delete existing record before creating the new one
const provider = CustomResourceProvider.getOrCreateProvider(this, DELETE_EXISTING_RECORD_SET_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'delete-existing-record-set-handler'),
runtime: CustomResourceProviderRuntime.NODEJS_14_X,
policyStatements: [{ // IAM permissions for all providers
Effect: 'Allow',
Action: 'route53:GetChange',
Resource: '*',
}],
});

provider.addToRolePolicy({ // Add to the singleton policy for this specific provider
Effect: 'Allow',
Action: [
'route53:ChangeResourceRecordSets',
'route53:ListResourceRecordSets',
],
Resource: props.zone.hostedZoneArn,
});

const customResource = new CustomResource(this, 'DeleteExistingRecordSetCustomResource', {
resourceType: DELETE_EXISTING_RECORD_SET_RESOURCE_TYPE,
serviceToken: provider.serviceToken,
properties: {
HostedZoneId: props.zone.hostedZoneId,
RecordName: recordName,
RecordType: props.recordType,
},
});

recordSet.node.addDependency(customResource);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const mockListResourceRecordSetsResponse = jest.fn();
const mockChangeResourceRecordSetsResponse = jest.fn();

const mockRoute53 = {
listResourceRecordSets: jest.fn().mockReturnValue({
promise: mockListResourceRecordSetsResponse,
}),
changeResourceRecordSets: jest.fn().mockReturnValue({
promise: mockChangeResourceRecordSetsResponse,
}),
waitFor: jest.fn().mockReturnValue({
promise: jest.fn().mockResolvedValue({}),
}),
};

jest.mock('aws-sdk', () => {
return {
Route53: jest.fn(() => mockRoute53),
};
});

import { handler } from '../lib/delete-existing-record-set-handler';

const event: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string } = {
RequestType: 'Create',
ServiceToken: 'service-token',
ResponseURL: 'response-url',
StackId: 'stack-id',
RequestId: 'request-id',
LogicalResourceId: 'logical-resource-id',
ResourceType: 'Custom::DeleteExistingRecordSet',
ResourceProperties: {
ServiceToken: 'service-token',
HostedZoneId: 'hosted-zone-id',
RecordName: 'dev.cdk.aws.',
RecordType: 'A',
},
};

beforeEach(() => {
jest.clearAllMocks();
});

test('create request with existing record', async () => {
mockListResourceRecordSetsResponse.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: 'dev.cdk.aws.',
Type: 'A',
TTL: 900,
},
{
Name: 'dev.cdk.aws.',
Type: 'AAAA',
TTL: 900,
},
],
});

mockChangeResourceRecordSetsResponse.mockResolvedValueOnce({
ChangeInfo: {
Id: 'change-id',
},
});

await handler(event);

expect(mockRoute53.listResourceRecordSets).toHaveBeenCalledWith({
HostedZoneId: 'hosted-zone-id',
StartRecordName: 'dev.cdk.aws.',
StartRecordType: 'A',
});

expect(mockRoute53.changeResourceRecordSets).toHaveBeenCalledWith({
HostedZoneId: 'hosted-zone-id',
ChangeBatch: {
Changes: [
{
Action: 'DELETE',
ResourceRecordSet: {
Name: 'dev.cdk.aws.',
TTL: 900,
Type: 'A',
},
},
],
},
});

expect(mockRoute53.waitFor).toHaveBeenCalledWith('resourceRecordSetsChanged', {
Id: 'change-id',
});
});

test('create request with non existing record', async () => {
mockListResourceRecordSetsResponse.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: 'www.cdk.aws.',
Type: 'A',
TTL: 900,
},
{
Name: 'dev.cdk.aws.',
Type: 'MX',
TTL: 900,
},
],
});

await handler(event);

expect(mockRoute53.changeResourceRecordSets).not.toHaveBeenCalled();
});

test('update request', async () => {
await handler({
...event,
RequestType: 'Update',
PhysicalResourceId: 'id',
OldResourceProperties: {},
});

expect(mockRoute53.changeResourceRecordSets).not.toHaveBeenCalled();
});

test('delete request', async () => {
await handler({
...event,
RequestType: 'Delete',
PhysicalResourceId: 'id',
});

expect(mockRoute53.changeResourceRecordSets).not.toHaveBeenCalled();
});

0 comments on commit 2f92c35

Please sign in to comment.