Skip to content

Commit f61d950

Browse files
authored
feat(redshift): optionally reboot Clusters to apply parameter changes (#22063)
Closes #22009 Currently waiting on #22055 and #22059 for the assertions in the integration test to successfully run ---- ### All Submissions: * [X] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/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/main/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*
1 parent 35059f3 commit f61d950

34 files changed

+9010
-8
lines changed

Diff for: packages/@aws-cdk/aws-redshift/README.md

+19
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,25 @@ const cluster = new Cluster(this, 'Cluster', {
349349
cluster.addToParameterGroup('enable_user_activity_logging', 'true');
350350
```
351351

352+
## Rebooting for Parameter Updates
353+
354+
In most cases, existing clusters [must be manually rebooted](https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-parameter-groups.html) to apply parameter changes. You can automate parameter related reboots by setting the cluster's `rebootForParameterChanges` property to `true` , or by using `Cluster.enableRebootForParameterChanges()`.
355+
356+
```ts
357+
declare const vpc: ec2.Vpc;
358+
359+
const cluster = new Cluster(this, 'Cluster', {
360+
masterUser: {
361+
masterUsername: 'admin',
362+
masterPassword: cdk.SecretValue.unsafePlainText('tooshort'),
363+
},
364+
vpc,
365+
});
366+
367+
cluster.addToParameterGroup('enable_user_activity_logging', 'true');
368+
cluster.enableRebootForParameterChanges()
369+
```
370+
352371
## Elastic IP
353372

354373
If you configure your cluster to be publicly accessible, you can optionally select an *elastic IP address* to use for the external IP address. An elastic IP address is a static IP address that is associated with your AWS account. You can use an elastic IP address to connect to your cluster from outside the VPC. An elastic IP address gives you the ability to change your underlying configuration without affecting the IP address that clients use to connect to your cluster. This approach can be helpful for situations such as recovery after a failure.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import { Redshift } from 'aws-sdk';
3+
4+
const redshift = new Redshift();
5+
6+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent): Promise<void> {
7+
if (event.RequestType !== 'Delete') {
8+
return rebootClusterIfRequired(event.ResourceProperties?.ClusterId, event.ResourceProperties?.ParameterGroupName);
9+
} else {
10+
return;
11+
}
12+
}
13+
14+
async function rebootClusterIfRequired(clusterId: string, parameterGroupName: string): Promise<void> {
15+
return executeActionForStatus(await getApplyStatus());
16+
17+
// https://docs.aws.amazon.com/redshift/latest/APIReference/API_ClusterParameterStatus.html
18+
async function executeActionForStatus(status: string, retryDurationMs?: number): Promise<void> {
19+
await sleep(retryDurationMs ?? 0);
20+
if (['pending-reboot', 'apply-deferred', 'apply-error'].includes(status)) {
21+
try {
22+
await redshift.rebootCluster({ ClusterIdentifier: clusterId }).promise();
23+
} catch (err) {
24+
if ((err as any).code === 'InvalidClusterState') {
25+
return await executeActionForStatus(status, 30000);
26+
} else {
27+
throw err;
28+
}
29+
}
30+
return;
31+
} else if (['applying', 'retry'].includes(status)) {
32+
return executeActionForStatus(await getApplyStatus(), 30000);
33+
}
34+
return;
35+
}
36+
37+
async function getApplyStatus(): Promise<string> {
38+
const clusterDetails = await redshift.describeClusters({ ClusterIdentifier: clusterId }).promise();
39+
if (clusterDetails.Clusters?.[0].ClusterParameterGroups === undefined) {
40+
throw new Error(`Unable to find any Parameter Groups associated with ClusterId "${clusterId}".`);
41+
}
42+
for (const group of clusterDetails.Clusters?.[0].ClusterParameterGroups) {
43+
if (group.ParameterGroupName === parameterGroupName) {
44+
return group.ParameterApplyStatus ?? 'retry';
45+
}
46+
}
47+
throw new Error(`Unable to find Parameter Group named "${parameterGroupName}" associated with ClusterId "${clusterId}".`);
48+
}
49+
}
50+
51+
function sleep(ms: number) {
52+
return new Promise(resolve => setTimeout(resolve, ms));
53+
}

Diff for: packages/@aws-cdk/aws-redshift/lib/cluster.ts

+78-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
import * as path from 'path';
12
import * as ec2 from '@aws-cdk/aws-ec2';
23
import * as iam from '@aws-cdk/aws-iam';
34
import * as kms from '@aws-cdk/aws-kms';
5+
import * as lambda from '@aws-cdk/aws-lambda';
46
import * as s3 from '@aws-cdk/aws-s3';
57
import * as secretsmanager from '@aws-cdk/aws-secretsmanager';
6-
import { Duration, IResource, Lazy, RemovalPolicy, Resource, SecretValue, Token } from '@aws-cdk/core';
7-
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from '@aws-cdk/custom-resources';
8+
import { ArnFormat, CustomResource, Duration, IResource, Lazy, RemovalPolicy, Resource, SecretValue, Stack, Token } from '@aws-cdk/core';
9+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId, Provider } from '@aws-cdk/custom-resources';
810
import { Construct } from 'constructs';
911
import { DatabaseSecret } from './database-secret';
1012
import { Endpoint } from './endpoint';
1113
import { ClusterParameterGroup, IClusterParameterGroup } from './parameter-group';
1214
import { CfnCluster } from './redshift.generated';
1315
import { ClusterSubnetGroup, IClusterSubnetGroup } from './subnet-group';
14-
1516
/**
1617
* Possible Node Types to use in the cluster
1718
* used for defining `ClusterProps.nodeType`.
@@ -364,6 +365,12 @@ export interface ClusterProps {
364365
*/
365366
readonly elasticIp?: string
366367

368+
/**
369+
* If this flag is set, the cluster will be rebooted when changes to the cluster's parameter group that require a restart to apply.
370+
* @default false
371+
*/
372+
readonly rebootForParameterChanges?: boolean
373+
367374
/**
368375
* If this flag is set, Amazon Redshift forces all COPY and UNLOAD traffic between your cluster and your data repositories through your virtual private cloud (VPC).
369376
*
@@ -592,7 +599,9 @@ export class Cluster extends ClusterBase {
592599

593600
const defaultPort = ec2.Port.tcp(this.clusterEndpoint.port);
594601
this.connections = new ec2.Connections({ securityGroups, defaultPort });
595-
602+
if (props.rebootForParameterChanges) {
603+
this.enableRebootForParameterChanges();
604+
}
596605
// Add default role if specified and also available in the roles list
597606
if (props.defaultRole) {
598607
if (props.roles?.some(x => x === props.defaultRole)) {
@@ -689,6 +698,71 @@ export class Cluster extends ClusterBase {
689698
}
690699
}
691700

701+
/**
702+
* Enables automatic cluster rebooting when changes to the cluster's parameter group require a restart to apply.
703+
*/
704+
public enableRebootForParameterChanges(): void {
705+
if (this.node.tryFindChild('RedshiftClusterRebooterCustomResource')) {
706+
return;
707+
}
708+
const rebootFunction = new lambda.SingletonFunction(this, 'RedshiftClusterRebooterFunction', {
709+
uuid: '511e207f-13df-4b8b-b632-c32b30b65ac2',
710+
runtime: lambda.Runtime.NODEJS_16_X,
711+
code: lambda.Code.fromAsset(path.join(__dirname, 'cluster-parameter-change-reboot-handler')),
712+
handler: 'index.handler',
713+
timeout: Duration.seconds(900),
714+
});
715+
rebootFunction.addToRolePolicy(new iam.PolicyStatement({
716+
actions: ['redshift:DescribeClusters'],
717+
resources: ['*'],
718+
}));
719+
rebootFunction.addToRolePolicy(new iam.PolicyStatement({
720+
actions: ['redshift:RebootCluster'],
721+
resources: [
722+
Stack.of(this).formatArn({
723+
service: 'redshift',
724+
resource: 'cluster',
725+
resourceName: this.clusterName,
726+
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
727+
}),
728+
],
729+
}));
730+
const provider = new Provider(this, 'ResourceProvider', {
731+
onEventHandler: rebootFunction,
732+
});
733+
const customResource = new CustomResource(this, 'RedshiftClusterRebooterCustomResource', {
734+
resourceType: 'Custom::RedshiftClusterRebooter',
735+
serviceToken: provider.serviceToken,
736+
properties: {
737+
ClusterId: this.clusterName,
738+
ParameterGroupName: Lazy.string({
739+
produce: () => {
740+
if (!this.parameterGroup) {
741+
throw new Error('Cannot enable reboot for parameter changes when there is no associated ClusterParameterGroup.');
742+
}
743+
return this.parameterGroup.clusterParameterGroupName;
744+
},
745+
}),
746+
ParametersString: Lazy.string({
747+
produce: () => {
748+
if (!(this.parameterGroup instanceof ClusterParameterGroup)) {
749+
throw new Error('Cannot enable reboot for parameter changes when using an imported parameter group.');
750+
}
751+
return JSON.stringify(this.parameterGroup.parameters);
752+
},
753+
}),
754+
},
755+
});
756+
Lazy.any({
757+
produce: () => {
758+
if (!this.parameterGroup) {
759+
throw new Error('Cannot enable reboot for parameter changes when there is no associated ClusterParameterGroup.');
760+
}
761+
customResource.node.addDependency(this, this.parameterGroup);
762+
},
763+
});
764+
}
765+
692766
/**
693767
* Adds default IAM role to cluster. The default IAM role must be already associated to the cluster to be added as the default role.
694768
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export declare function handler(event: AWSLambda.CloudFormationCustomResourceEvent): Promise<void>;

Diff for: packages/@aws-cdk/aws-redshift/test/cluster-reboot.integ.snapshot/asset.1b88b7c3e3e0f8d3e27ded1bde51b7a80c75f3d8733872af7952c3a6d902147e/index.js

+56
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import { Redshift } from 'aws-sdk';
3+
4+
const redshift = new Redshift();
5+
6+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent): Promise<void> {
7+
if (event.RequestType !== 'Delete') {
8+
return rebootClusterIfRequired(event.ResourceProperties?.ClusterId, event.ResourceProperties?.ParameterGroupName);
9+
} else {
10+
return;
11+
}
12+
}
13+
14+
async function rebootClusterIfRequired(clusterId: string, parameterGroupName: string): Promise<void> {
15+
return executeActionForStatus(await getApplyStatus());
16+
17+
// https://docs.aws.amazon.com/redshift/latest/APIReference/API_ClusterParameterStatus.html
18+
async function executeActionForStatus(status: string, retryDurationMs?: number): Promise<void> {
19+
await sleep(retryDurationMs ?? 0);
20+
if (['pending-reboot', 'apply-deferred', 'apply-error', 'unknown-error'].includes(status)) {
21+
try {
22+
await redshift.rebootCluster({ ClusterIdentifier: clusterId }).promise();
23+
} catch (err) {
24+
if ((<any>err).code === 'InvalidClusterState') {
25+
return await executeActionForStatus(status, 30000);
26+
} else {
27+
throw err;
28+
}
29+
}
30+
return;
31+
} else if (['applying', 'retry'].includes(status)) {
32+
return executeActionForStatus(await getApplyStatus(), 30000);
33+
}
34+
return;
35+
}
36+
37+
async function getApplyStatus(): Promise<string> {
38+
const clusterDetails = await redshift.describeClusters({ ClusterIdentifier: clusterId }).promise();
39+
if (clusterDetails.Clusters?.[0].ClusterParameterGroups === undefined) {
40+
throw new Error(`Unable to find any Parameter Groups associated with ClusterId "${clusterId}".`);
41+
}
42+
for (const group of clusterDetails.Clusters?.[0].ClusterParameterGroups) {
43+
if (group.ParameterGroupName === parameterGroupName) {
44+
return group.ParameterApplyStatus ?? 'retry';
45+
}
46+
}
47+
throw new Error(`Unable to find Parameter Group named "${parameterGroupName}" associated with ClusterId "${clusterId}".`);
48+
}
49+
}
50+
51+
function sleep(ms: number) {
52+
return new Promise(resolve => setTimeout(resolve, ms));
53+
}

0 commit comments

Comments
 (0)