diff --git a/packages/aws-cdk-lib/aws-rds/lib/cluster.ts b/packages/aws-cdk-lib/aws-rds/lib/cluster.ts index 7284a8c42b1e3..e88a0c5224340 100644 --- a/packages/aws-cdk-lib/aws-rds/lib/cluster.ts +++ b/packages/aws-cdk-lib/aws-rds/lib/cluster.ts @@ -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'; @@ -829,36 +830,10 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase { }); } + validateDatabaseClusterProps(scope, props); + const enablePerformanceInsights = props.enablePerformanceInsights || props.performanceInsightRetention !== undefined || props.performanceInsightEncryptionKey !== undefined; - if (enablePerformanceInsights && props.enablePerformanceInsights === false) { - throw new Error('`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set'); - } - - if (props.clusterScalabilityType === ClusterScalabilityType.LIMITLESS || props.clusterScailabilityType === ClusterScailabilityType.LIMITLESS) { - if (!props.enablePerformanceInsights) { - throw new Error('Performance Insights must be enabled for Aurora Limitless Database.'); - } - if (!props.performanceInsightRetention || props.performanceInsightRetention < PerformanceInsightRetention.MONTHS_1) { - throw new Error('Performance Insights retention period must be set at least 31 days for Aurora Limitless Database.'); - } - if (!props.monitoringInterval || !props.enableClusterLevelEnhancedMonitoring) { - throw new Error('Cluster level enhanced monitoring must be set for Aurora Limitless Database. Please set \'monitoringInterval\' and enable \'enableClusterLevelEnhancedMonitoring\'.'); - } - if (props.writer || props.readers) { - throw new Error('Aurora Limitless Database does not support readers or writer instances.'); - } - if (!props.engine.engineVersion?.fullVersion?.endsWith('limitless')) { - throw new Error(`Aurora Limitless Database requires an engine version that supports it, got ${props.engine.engineVersion?.fullVersion}`); - } - if (props.storageType !== DBClusterStorageType.AURORA_IOPT1) { - throw new Error(`Aurora Limitless Database requires I/O optimized storage type, got: ${props.storageType}`); - } - if (props.cloudwatchLogsExports === undefined || props.cloudwatchLogsExports.length === 0) { - throw new Error('Aurora Limitless Database requires CloudWatch Logs exports to be set.'); - } - } - this.performanceInsightsEnabled = enablePerformanceInsights; this.performanceInsightRetention = enablePerformanceInsights ? (props.performanceInsightRetention || PerformanceInsightRetention.DEFAULT) diff --git a/packages/aws-cdk-lib/aws-rds/lib/validate-database-cluster-props.ts b/packages/aws-cdk-lib/aws-rds/lib/validate-database-cluster-props.ts new file mode 100644 index 0000000000000..e21ff1c2edbe2 --- /dev/null +++ b/packages/aws-cdk-lib/aws-rds/lib/validate-database-cluster-props.ts @@ -0,0 +1,54 @@ +import { Construct } from 'constructs'; +import { ClusterScailabilityType, DatabaseCluster, DatabaseClusterProps, DBClusterStorageType } from './cluster'; +import { PerformanceInsightRetention } from './props'; +import { validateProps, ValidationRule } from '../../core/lib/helpers-internal'; + +const standardDatabaseRules: ValidationRule[] = [ + { + condition: (props) => props.enablePerformanceInsights === false && + (props.performanceInsightRetention !== undefined || props.performanceInsightEncryptionKey !== undefined), + message: () => '`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set', + + }, +]; + +const limitlessDatabaseRules: ValidationRule[] = [ + { + 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; + + validateProps(scope, DatabaseCluster.name, props, applicableRules); +} diff --git a/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts b/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts index 9c340a6e41378..ce16e4fd5dfc2 100644 --- a/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts +++ b/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts @@ -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'); }); test('throw error for invalid performance insights retention period', () => { @@ -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', () => { @@ -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) => { @@ -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', () => { @@ -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([ @@ -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', () => { @@ -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'); }); }); @@ -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', () => { @@ -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', () => { diff --git a/packages/aws-cdk-lib/aws-sqs/lib/queue.ts b/packages/aws-cdk-lib/aws-sqs/lib/queue.ts index d05f886cc22a8..b8ca6d2d4fb1a 100644 --- a/packages/aws-cdk-lib/aws-sqs/lib/queue.ts +++ b/packages/aws-cdk-lib/aws-sqs/lib/queue.ts @@ -1,7 +1,7 @@ import { Construct } from 'constructs'; import { IQueue, QueueAttributes, QueueBase, QueueEncryption } from './queue-base'; import { CfnQueue } from './sqs.generated'; -import { validateProps } from './validate-props'; +import { validateQueueProps } from './validate-queue-props'; import * as iam from '../../aws-iam'; import * as kms from '../../aws-kms'; import { Duration, RemovalPolicy, Stack, Token, ArnFormat, Annotations } from '../../core'; @@ -383,7 +383,7 @@ export class Queue extends QueueBase { physicalName: props.queueName, }); - validateProps(props); + validateQueueProps(scope, props); if (props.redriveAllowPolicy) { const { redrivePermission, sourceQueues } = props.redriveAllowPolicy; diff --git a/packages/aws-cdk-lib/aws-sqs/lib/validate-props.ts b/packages/aws-cdk-lib/aws-sqs/lib/validate-props.ts deleted file mode 100644 index 770955b9d03e4..0000000000000 --- a/packages/aws-cdk-lib/aws-sqs/lib/validate-props.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { QueueProps } from './index'; -import { Token } from '../../core'; - -export function validateProps(props: QueueProps) { - validateRange('delivery delay', props.deliveryDelay && props.deliveryDelay.toSeconds(), 0, 900, 'seconds'); - validateRange('maximum message size', props.maxMessageSizeBytes, 1_024, 262_144, 'bytes'); - validateRange('message retention period', props.retentionPeriod && props.retentionPeriod.toSeconds(), 60, 1_209_600, 'seconds'); - validateRange('receive wait time', props.receiveMessageWaitTime && props.receiveMessageWaitTime.toSeconds(), 0, 20, 'seconds'); - validateRange('visibility timeout', props.visibilityTimeout && props.visibilityTimeout.toSeconds(), 0, 43_200, 'seconds'); - validateRange('dead letter target maximum receive count', props.deadLetterQueue && props.deadLetterQueue.maxReceiveCount, 1, +Infinity); -} - -function validateRange(label: string, value: number | undefined, minValue: number, maxValue: number, unit?: string) { - if (value === undefined || Token.isUnresolved(value)) { return; } - const unitSuffix = unit ? ` ${unit}` : ''; - if (value < minValue) { throw new Error(`${label} must be ${minValue}${unitSuffix} or more, but ${value} was provided`); } - if (value > maxValue) { throw new Error(`${label} must be ${maxValue}${unitSuffix} or less, but ${value} was provided`); } -} diff --git a/packages/aws-cdk-lib/aws-sqs/lib/validate-queue-props.ts b/packages/aws-cdk-lib/aws-sqs/lib/validate-queue-props.ts new file mode 100644 index 0000000000000..06eca886491b4 --- /dev/null +++ b/packages/aws-cdk-lib/aws-sqs/lib/validate-queue-props.ts @@ -0,0 +1,39 @@ +import { Construct } from 'constructs'; +import { Queue, QueueProps } from './index'; +import { Token } from '../../core'; +import { validateProps, ValidationRule } from '../../core/lib/helpers-internal'; + +function validateRange(value: number | undefined, minValue: number, maxValue: number): boolean { + return value !== undefined && !Token.isUnresolved(value) && (value < minValue || value > maxValue); +} + +const queueValidationRules: ValidationRule[] = [ + { + condition: (props) => validateRange(props.deliveryDelay?.toSeconds(), 0, 900), + message: (props) => `delivery delay must be between 0 and 900 seconds, but ${props.deliveryDelay?.toSeconds()} was provided`, + }, + { + condition: (props) => validateRange(props.maxMessageSizeBytes, 1_024, 262_144), + message: (props) => `maximum message size must be between 1,024 and 262,144 bytes, but ${props.maxMessageSizeBytes} was provided`, + }, + { + condition: (props) => validateRange(props.retentionPeriod?.toSeconds(), 60, 1_209_600), + message: (props) => `message retention period must be between 60 and 1,209,600 seconds, but ${props.retentionPeriod?.toSeconds()} was provided`, + }, + { + condition: (props) => validateRange(props.receiveMessageWaitTime?.toSeconds(), 0, 20), + message: (props) => `receive wait time must be between 0 and 20 seconds, but ${props.receiveMessageWaitTime?.toSeconds()} was provided`, + }, + { + condition: (props) => validateRange(props.visibilityTimeout?.toSeconds(), 0, 43_200), + message: (props) => `visibility timeout must be between 0 and 43,200 seconds, but ${props.visibilityTimeout?.toSeconds()} was provided`, + }, + { + condition: (props) => validateRange(props.deadLetterQueue?.maxReceiveCount, 1, Number.MAX_SAFE_INTEGER), + message: (props) => `dead letter target maximum receive count must be 1 or more, but ${props.deadLetterQueue?.maxReceiveCount} was provided`, + }, +]; + +export function validateQueueProps(scope: Construct, props: QueueProps) { + validateProps(scope, Queue.name, props, queueValidationRules); +} diff --git a/packages/aws-cdk-lib/aws-sqs/test/sqs.test.ts b/packages/aws-cdk-lib/aws-sqs/test/sqs.test.ts index 690eb0ec275be..21df5640ea53e 100644 --- a/packages/aws-cdk-lib/aws-sqs/test/sqs.test.ts +++ b/packages/aws-cdk-lib/aws-sqs/test/sqs.test.ts @@ -62,6 +62,17 @@ test('with a dead letter queue', () => { expect(queue.deadLetterQueue).toEqual(dlqProps); }); +test('multiple prop validation errors are presented to the user (out-of-range retentionPeriod and deliveryDelay)', () => { + // GIVEN + const stack = new Stack(); + + // THEN + expect(() => new sqs.Queue(stack, 'MyQueue', { + retentionPeriod: Duration.seconds(30), + deliveryDelay: Duration.minutes(16), + })).toThrow('Queue initialization failed due to the following validation error(s):\n- delivery delay must be between 0 and 900 seconds, but 960 was provided\n- message retention period must be between 60 and 1,209,600 seconds, but 30 was provided'); +}); + test('message retention period must be between 1 minute to 14 days', () => { // GIVEN const stack = new Stack(); @@ -69,11 +80,11 @@ test('message retention period must be between 1 minute to 14 days', () => { // THEN expect(() => new sqs.Queue(stack, 'MyQueue', { retentionPeriod: Duration.seconds(30), - })).toThrow(/message retention period must be 60 seconds or more/); + })).toThrow('Queue initialization failed due to the following validation error(s):\n- message retention period must be between 60 and 1,209,600 seconds, but 30 was provided'); expect(() => new sqs.Queue(stack, 'AnotherQueue', { retentionPeriod: Duration.days(15), - })).toThrow(/message retention period must be 1209600 seconds or less/); + })).toThrow('Queue initialization failed due to the following validation error(s):\n- message retention period must be between 60 and 1,209,600 seconds, but 1296000 was provided'); }); test('message retention period can be provided as a parameter', () => { diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts index 50003af38ab8f..0fa86b8304608 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts @@ -3,4 +3,5 @@ export * from './cfn-parse'; export { md5hash } from '../private/md5'; export * from './customize-roles'; export * from './string-specializer'; +export * from './validate-props'; export { constructInfoFromConstruct, constructInfoFromStack } from '../private/runtime-info'; diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/validate-props.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/validate-props.ts new file mode 100644 index 0000000000000..82bd6d000d6c2 --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/validate-props.ts @@ -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 = { + /** + * A function that checks if the validation rule condition is met. + * @param {T} props - The props 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. + * @param {T} props - The props to validate. + * @param {ValidationRule[]} rules - An array of validation rules to apply. + * @throws {Error} If any validation rules fail, with a message detailing all failures. + */ +export function validateProps(scope: Construct, className: string, props: T, rules: ValidationRule[]): 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); + } +} diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/validate-props.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/validate-props.test.ts new file mode 100644 index 0000000000000..a93d1ebb27d46 --- /dev/null +++ b/packages/aws-cdk-lib/core/test/helpers-internal/validate-props.test.ts @@ -0,0 +1,90 @@ +import { Construct } from 'constructs'; +import { ValidationError } from '../../lib/errors'; +import { validateProps, ValidationRule } from '../../lib/helpers-internal/validate-props'; + +class MockConstruct extends Construct { + constructor() { + super(undefined as any, 'MockConstruct'); + } +} + +describe('validateProps', () => { + let mockScope: Construct; + + beforeEach(() => { + mockScope = new MockConstruct(); + }); + + it('should not throw an error when all validations pass', () => { + const props = { value: 5 }; + const rules: ValidationRule[] = [ + { + condition: (p) => p.value < 0, + message: (p) => `Value ${p.value} should be non-negative`, + }, + ]; + + expect(() => validateProps(mockScope, 'TestClass', props, rules)).not.toThrow(); + }); + + it('should throw a ValidationError when a validation fails', () => { + const props = { value: -5 }; + const rules: ValidationRule[] = [ + { + condition: (p) => p.value < 0, + message: (p) => `Value ${p.value} should be non-negative`, + }, + ]; + + expect(() => validateProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + }); + + it('should include all failed validation messages in the error', () => { + const props = { value1: -5, value2: 15 }; + const rules: ValidationRule[] = [ + { + 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(() => validateProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + try { + validateProps(mockScope, '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[] = [ + { + condition: (p) => p.nested.value !== 'valid', + message: (p) => `Nested value "${p.nested.value}" is not valid`, + }, + ]; + + expect(() => validateProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + try { + validateProps(mockScope, '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', + ); + } + } + }); +});