Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(rds): does not print all failed validations for DatabaseCluster props #32841

Merged
merged 3 commits into from
Jan 23, 2025
Merged
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
31 changes: 3 additions & 28 deletions packages/aws-cdk-lib/aws-rds/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { BackupProps, Credentials, InstanceProps, PerformanceInsightRetention, R
import { DatabaseProxy, DatabaseProxyOptions, ProxyTarget } from './proxy';
import { CfnDBCluster, CfnDBClusterProps, CfnDBInstance } from './rds.generated';
import { ISubnetGroup, SubnetGroup } from './subnet-group';
import { validateDatabaseClusterProps } from './validate-database-cluster-props';
import * as cloudwatch from '../../aws-cloudwatch';
import * as ec2 from '../../aws-ec2';
import * as iam from '../../aws-iam';
Expand Down Expand Up @@ -830,36 +831,10 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase {
});
}

validateDatabaseClusterProps(this, props);

const enablePerformanceInsights = props.enablePerformanceInsights
|| props.performanceInsightRetention !== undefined || props.performanceInsightEncryptionKey !== undefined;
if (enablePerformanceInsights && props.enablePerformanceInsights === false) {
throw new ValidationError('`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set', this);
}

if (props.clusterScalabilityType === ClusterScalabilityType.LIMITLESS || props.clusterScailabilityType === ClusterScailabilityType.LIMITLESS) {
if (!props.enablePerformanceInsights) {
throw new ValidationError('Performance Insights must be enabled for Aurora Limitless Database.', this);
}
if (!props.performanceInsightRetention || props.performanceInsightRetention < PerformanceInsightRetention.MONTHS_1) {
throw new ValidationError('Performance Insights retention period must be set at least 31 days for Aurora Limitless Database.', this);
}
if (!props.monitoringInterval || !props.enableClusterLevelEnhancedMonitoring) {
throw new ValidationError('Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'.', this);
}
if (props.writer || props.readers) {
throw new ValidationError('Aurora Limitless Database does not support readers or writer instances.', this);
}
if (!props.engine.engineVersion?.fullVersion?.endsWith('limitless')) {
throw new ValidationError(`Aurora Limitless Database requires an engine version that supports it, got ${props.engine.engineVersion?.fullVersion}`, this);
}
if (props.storageType !== DBClusterStorageType.AURORA_IOPT1) {
throw new ValidationError(`Aurora Limitless Database requires I/O optimized storage type, got: ${props.storageType}`, this);
}
if (props.cloudwatchLogsExports === undefined || props.cloudwatchLogsExports.length === 0) {
throw new ValidationError('Aurora Limitless Database requires CloudWatch Logs exports to be set.', this);
}
}

this.performanceInsightsEnabled = enablePerformanceInsights;
this.performanceInsightRetention = enablePerformanceInsights
? (props.performanceInsightRetention || PerformanceInsightRetention.DEFAULT)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Construct } from 'constructs';
import { ClusterScailabilityType, DatabaseCluster, DatabaseClusterProps, DBClusterStorageType } from './cluster';
import { PerformanceInsightRetention } from './props';
import { validateAllProps, ValidationRule } from '../../core/lib/helpers-internal';

const standardDatabaseRules: ValidationRule<DatabaseClusterProps>[] = [
{
condition: (props) => props.enablePerformanceInsights === false &&
(props.performanceInsightRetention !== undefined || props.performanceInsightEncryptionKey !== undefined),
message: () => '`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set',

},
];

const limitlessDatabaseRules: ValidationRule<DatabaseClusterProps>[] = [
{
condition: (props) => !props.enablePerformanceInsights,
message: () => 'Performance Insights must be enabled for Aurora Limitless Database',
},
{
condition: (props) => !props.performanceInsightRetention
|| props.performanceInsightRetention < PerformanceInsightRetention.MONTHS_1,
message: () => 'Performance Insights retention period must be set to at least 31 days for Aurora Limitless Database',
},
{
condition: (props) => !props.monitoringInterval || !props.enableClusterLevelEnhancedMonitoring,
message: () => 'Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'',
},
{
condition: (props) => !!(props.writer || props.readers),
message: () => 'Aurora Limitless Database does not support reader or writer instances',
},
{
condition: (props) => !props.engine.engineVersion?.fullVersion?.endsWith('limitless'),
message: (props) => `Aurora Limitless Database requires an engine version that supports it, got: ${props.engine.engineVersion?.fullVersion}`,
},
{
condition: (props) => props.storageType !== DBClusterStorageType.AURORA_IOPT1,
message: (props) => `Aurora Limitless Database requires I/O optimized storage type, got: ${props.storageType}`,
},
{
condition: (props) => props.cloudwatchLogsExports === undefined || props.cloudwatchLogsExports.length === 0,
message: () => 'Aurora Limitless Database requires CloudWatch Logs exports to be set',
},
];

export function validateDatabaseClusterProps(scope: Construct, props: DatabaseClusterProps): void {
const isLimitlessCluster = props.clusterScailabilityType === ClusterScailabilityType.LIMITLESS;
const applicableRules = isLimitlessCluster
? [...standardDatabaseRules, ...limitlessDatabaseRules]
: standardDatabaseRules;

validateAllProps(scope, DatabaseCluster.name, props, applicableRules);
}
18 changes: 9 additions & 9 deletions packages/aws-cdk-lib/aws-rds/test/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe('cluster new api', () => {
storageType: DBClusterStorageType.AURORA_IOPT1,
cloudwatchLogsExports: ['postgresql'],
});
}).toThrow('Performance Insights must be enabled for Aurora Limitless Database.');
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- Performance Insights must be enabled for Aurora Limitless Database\n- Performance Insights retention period must be set to at least 31 days for Aurora Limitless Database');
Copy link
Contributor

Choose a reason for hiding this comment

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

💅🏻 nit: Might be nicer to limit the test exception to what the test is trying to test. Like for this specific test, do we really care of the other error and the prelude is included?

We could keep the existing ones and then have a single test case that checks all possible parallel rules

Copy link
Contributor Author

@iankhou iankhou Jan 22, 2025

Choose a reason for hiding this comment

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

The way that the validations are implemented now, the prelude will always be included. Since we were only testing for Performance Insights must be enabled for Aurora Limitless Database, I could change the test to include a valid retention period. However, this seems kind of contrived... since a user is unlikely to include a retention period if they did not enable performance insights.

});

test('throw error for invalid performance insights retention period', () => {
Expand All @@ -292,7 +292,7 @@ describe('cluster new api', () => {
storageType: DBClusterStorageType.AURORA_IOPT1,
cloudwatchLogsExports: ['postgresql'],
});
}).toThrow('Performance Insights retention period must be set at least 31 days for Aurora Limitless Database.');
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- Performance Insights retention period must be set to at least 31 days for Aurora Limitless Database');
});

test('throw error for not specifying monitoring interval', () => {
Expand All @@ -316,7 +316,7 @@ describe('cluster new api', () => {
storageType: DBClusterStorageType.AURORA_IOPT1,
cloudwatchLogsExports: ['postgresql'],
});
}).toThrow('Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'.');
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'');
});

test.each([false, undefined])('throw error for configuring enhanced monitoring at the instance level', (enableClusterLevelEnhancedMonitoring) => {
Expand All @@ -341,7 +341,7 @@ describe('cluster new api', () => {
cloudwatchLogsExports: ['postgresql'],
instances: 1,
});
}).toThrow('Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'.');
}).toThrow('Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'');
});

test('throw error for specifying writer instance', () => {
Expand All @@ -366,7 +366,7 @@ describe('cluster new api', () => {
cloudwatchLogsExports: ['postgresql'],
writer: ClusterInstance.serverlessV2('writer'),
});
}).toThrow('Aurora Limitless Database does not support readers or writer instances.');
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- Aurora Limitless Database does not support reader or writer instances');
});

test.each([
Expand Down Expand Up @@ -395,7 +395,7 @@ describe('cluster new api', () => {
storageType: DBClusterStorageType.AURORA_IOPT1,
cloudwatchLogsExports: ['postgresql'],
});
}).toThrow(`Aurora Limitless Database requires an engine version that supports it, got ${engine.engineVersion?.fullVersion}`);
}).toThrow(`DatabaseCluster initialization failed due to the following validation error(s):\n- Aurora Limitless Database requires an engine version that supports it, got: ${engine.engineVersion?.fullVersion}`);
});

test('throw error for invalid storage type', () => {
Expand Down Expand Up @@ -443,7 +443,7 @@ describe('cluster new api', () => {
storageType: DBClusterStorageType.AURORA_IOPT1,
cloudwatchLogsExports,
});
}).toThrow('Aurora Limitless Database requires CloudWatch Logs exports to be set.');
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- Aurora Limitless Database requires CloudWatch Logs exports to be set');
});
});

Expand Down Expand Up @@ -2130,7 +2130,7 @@ describe('cluster', () => {
enablePerformanceInsights: false,
performanceInsightRetention: PerformanceInsightRetention.DEFAULT,
});
}).toThrow(/`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set/);
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- `enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set');
});

test('throws if performanceInsightEncryptionKey is set but performance insights is disabled', () => {
Expand All @@ -2142,7 +2142,7 @@ describe('cluster', () => {
enablePerformanceInsights: false,
performanceInsightRetention: PerformanceInsightRetention.DEFAULT,
});
}).toThrow(/`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set/);
}).toThrow('DatabaseCluster initialization failed due to the following validation error(s):\n- `enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set');
});

test('warn if performance insights is enabled at cluster level but disabled on writer and reader instances', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk-lib/core/lib/helpers-internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './cfn-parse';
export { md5hash } from '../private/md5';
export * from './customize-roles';
export * from './string-specializer';
export * from './validate-all-props';
export { constructInfoFromConstruct, constructInfoFromStack } from '../private/runtime-info';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Construct } from 'constructs';
import { ValidationError } from '../errors';

/**
* Represents a validation rule for props of type T.
* @template T The type of the props being validated.
*/
export type ValidationRule<T> = {
/**
* A function that checks if the validation rule condition is met.
* @param {T} props - The props object to validate.
* @returns {boolean} True if the condition is met (i.e., validation fails), false otherwise.
*/
condition: (props: T) => boolean;

/**
* A function that returns an error message if the validation fails.
* @param {T} props - The props that failed validation.
* @returns {string} The error message.
*/
message: (props: T) => string;
};

/**
* Validates props against a set of rules and throws an error if any validations fail.
*
* @template T The type of the props being validated.
* @param {string} className - The name of the class being validated, used in the error message. Ex. for SQS, might be Queue.name
* @param {T} props - The props object to validate.
* @param {ValidationRule<T>[]} rules - An array of validation rules to apply.
* @throws {Error} If any validation rules fail, with a message detailing all failures.
*/
export function validateAllProps<T>(scope: Construct, className: string, props: T, rules: ValidationRule<T>[]): void {
const validationErrors = rules
.filter(rule => rule.condition(props))
.map(rule => rule.message(props));

if (validationErrors.length > 0) {
const errorMessage = `${className} initialization failed due to the following validation error(s):\n${validationErrors.map(error => `- ${error}`).join('\n')}`;
throw new ValidationError(errorMessage, scope);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Construct } from 'constructs';
import { ValidationError } from '../../lib/errors';
import { validateAllProps, ValidationRule } from '../../lib/helpers-internal/validate-all-props';

class TestConstruct extends Construct {
constructor() {
super(undefined as any, 'TestConstruct');
}
}

describe('validateAllProps', () => {
let testScope: Construct;

beforeEach(() => {
testScope = new TestConstruct();
});

it('should not throw an error when all validations pass', () => {
const props = { value: 5 };
const rules: ValidationRule<typeof props>[] = [
{
condition: (p) => p.value < 0,
message: (p) => `Value ${p.value} should be non-negative`,
},
];

expect(() => validateAllProps(testScope, 'TestClass', props, rules)).not.toThrow();
});

it('should throw a ValidationError when a validation fails', () => {
const props = { value: -5 };
const rules: ValidationRule<typeof props>[] = [
{
condition: (p) => p.value < 0,
message: (p) => `Value ${p.value} should be non-negative`,
},
];

expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError);
});

it('should include all failed validation messages in the error', () => {
const props = { value1: -5, value2: 15 };
const rules: ValidationRule<typeof props>[] = [
{
condition: (p) => p.value1 < 0,
message: (p) => `Value1 ${p.value1} should be non-negative`,
},
{
condition: (p) => p.value2 > 10,
message: (p) => `Value2 ${p.value2} should be 10 or less`,
},
];

expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError);
try {
validateAllProps(testScope, 'TestClass', props, rules);
} catch (error) {
if (error instanceof ValidationError) {
expect(error.message).toBe(
'TestClass initialization failed due to the following validation error(s):\n' +
'- Value1 -5 should be non-negative\n' +
'- Value2 15 should be 10 or less',
);
}
}
});

it('should work with complex object structures', () => {
const props = { nested: { value: 'invalid' } };
const rules: ValidationRule<typeof props>[] = [
{
condition: (p) => p.nested.value !== 'valid',
message: (p) => `Nested value "${p.nested.value}" is not valid`,
},
];

expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError);
try {
validateAllProps(testScope, 'TestClass', props, rules);
} catch (error) {
if (error instanceof ValidationError) {
expect(error.message).toBe(
'TestClass initialization failed due to the following validation error(s):\n' +
'- Nested value "invalid" is not valid',
);
}
}
});
});
Loading