From 64a11253a21e326d2f5c17ab9d4c38a9a95ce5bf Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:55:33 -0500 Subject: [PATCH 1/2] Merge conflict --- packages/aws-cdk-lib/aws-rds/lib/cluster.ts | 31 +------ .../lib/validate-database-cluster-props.ts | 54 +++++++++++ .../aws-cdk-lib/aws-rds/test/cluster.test.ts | 18 ++-- .../core/lib/helpers-internal/index.ts | 1 + .../helpers-internal/validate-all-props.ts | 42 +++++++++ .../validate-all-props.test.ts | 90 +++++++++++++++++++ 6 files changed, 199 insertions(+), 37 deletions(-) create mode 100644 packages/aws-cdk-lib/aws-rds/lib/validate-database-cluster-props.ts create mode 100644 packages/aws-cdk-lib/core/lib/helpers-internal/validate-all-props.ts create mode 100644 packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts diff --git a/packages/aws-cdk-lib/aws-rds/lib/cluster.ts b/packages/aws-cdk-lib/aws-rds/lib/cluster.ts index c7261601c8e6a..9870753a8017a 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'; @@ -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) 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..23b3896385a3e --- /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 { validateAllProps, 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; + + validateAllProps(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/core/lib/helpers-internal/index.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts index 50003af38ab8f..5d85b9399403d 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-all-props'; export { constructInfoFromConstruct, constructInfoFromStack } from '../private/runtime-info'; diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/validate-all-props.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/validate-all-props.ts new file mode 100644 index 0000000000000..59f580d12754d --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/validate-all-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 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[]} rules - An array of validation rules to apply. + * @throws {Error} If any validation rules fail, with a message detailing all failures. + */ +export function validateAllProps(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-all-props.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts new file mode 100644 index 0000000000000..8a926737a5387 --- /dev/null +++ b/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts @@ -0,0 +1,90 @@ +import { Construct } from 'constructs'; +import { ValidationError } from '../../lib/errors'; +import { validateAllProps, ValidationRule } from '../../lib/helpers-internal/validate-all-props'; + +class MockConstruct extends Construct { + constructor() { + super(undefined as any, 'MockConstruct'); + } +} + +describe('validateAllProps', () => { + 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(() => validateAllProps(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(() => validateAllProps(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(() => validateAllProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + try { + validateAllProps(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(() => validateAllProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + try { + validateAllProps(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', + ); + } + } + }); +}); From 9450eb8b454dc41d09501e7805e7e7b4389920eb Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 22 Jan 2025 13:54:12 -0500 Subject: [PATCH 2/2] WIP --- .../validate-all-props.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts index 8a926737a5387..78948be563825 100644 --- a/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts +++ b/packages/aws-cdk-lib/core/test/helpers-internal/validate-all-props.test.ts @@ -2,17 +2,17 @@ import { Construct } from 'constructs'; import { ValidationError } from '../../lib/errors'; import { validateAllProps, ValidationRule } from '../../lib/helpers-internal/validate-all-props'; -class MockConstruct extends Construct { +class TestConstruct extends Construct { constructor() { - super(undefined as any, 'MockConstruct'); + super(undefined as any, 'TestConstruct'); } } describe('validateAllProps', () => { - let mockScope: Construct; + let testScope: Construct; beforeEach(() => { - mockScope = new MockConstruct(); + testScope = new TestConstruct(); }); it('should not throw an error when all validations pass', () => { @@ -24,7 +24,7 @@ describe('validateAllProps', () => { }, ]; - expect(() => validateAllProps(mockScope, 'TestClass', props, rules)).not.toThrow(); + expect(() => validateAllProps(testScope, 'TestClass', props, rules)).not.toThrow(); }); it('should throw a ValidationError when a validation fails', () => { @@ -36,7 +36,7 @@ describe('validateAllProps', () => { }, ]; - expect(() => validateAllProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError); }); it('should include all failed validation messages in the error', () => { @@ -52,9 +52,9 @@ describe('validateAllProps', () => { }, ]; - expect(() => validateAllProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError); try { - validateAllProps(mockScope, 'TestClass', props, rules); + validateAllProps(testScope, 'TestClass', props, rules); } catch (error) { if (error instanceof ValidationError) { expect(error.message).toBe( @@ -75,9 +75,9 @@ describe('validateAllProps', () => { }, ]; - expect(() => validateAllProps(mockScope, 'TestClass', props, rules)).toThrow(ValidationError); + expect(() => validateAllProps(testScope, 'TestClass', props, rules)).toThrow(ValidationError); try { - validateAllProps(mockScope, 'TestClass', props, rules); + validateAllProps(testScope, 'TestClass', props, rules); } catch (error) { if (error instanceof ValidationError) { expect(error.message).toBe(