diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-rds/test/integ.cluster-snapshot.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-rds/test/integ.cluster-snapshot.ts index ea4afa157caa4..0cbe95033dd21 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-rds/test/integ.cluster-snapshot.ts +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-rds/test/integ.cluster-snapshot.ts @@ -9,6 +9,7 @@ import * as rds from 'aws-cdk-lib/aws-rds'; import { ClusterInstance } from 'aws-cdk-lib/aws-rds'; import { IntegTest } from '@aws-cdk/integ-tests-alpha'; import { STANDARD_NODEJS_RUNTIME } from '../../config'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; class TestStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { @@ -107,6 +108,10 @@ class Snapshoter extends Construct { const provider = new cr.Provider(this, 'SnapshotProvider', { onEventHandler, isCompleteHandler, + logOptionsForWaiterStateMachine: { + logRetention: RetentionDays.ONE_DAY, + }, + disableLoggingForWaiterStateMachine: false, }); const customResource = new CustomResource(this, 'Snapshot', { diff --git a/packages/aws-cdk-lib/custom-resources/lib/provider-framework/provider.ts b/packages/aws-cdk-lib/custom-resources/lib/provider-framework/provider.ts index a0690b3d5055e..47b15848ef6ac 100644 --- a/packages/aws-cdk-lib/custom-resources/lib/provider-framework/provider.ts +++ b/packages/aws-cdk-lib/custom-resources/lib/provider-framework/provider.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import { Construct } from 'constructs'; import * as consts from './runtime/consts'; import { calculateRetryPolicy } from './util'; -import { WaiterStateMachine } from './waiter-state-machine'; +import { LogOptions, WaiterStateMachine } from './waiter-state-machine'; import { CustomResourceProviderConfig, ICustomResourceProvider } from '../../../aws-cloudformation'; import * as ec2 from '../../../aws-ec2'; import * as iam from '../../../aws-iam'; @@ -126,6 +126,25 @@ export interface ProviderProps { * @default - AWS Lambda creates and uses an AWS managed customer master key (CMK) */ readonly providerFunctionEnvEncryption?: kms.IKey; + + /** + * Log options for `WaiterStateMachine` with `isCompleteHandler` in the provider. + * + * This must not be used if `isCompleteHandler` is not specified or + * `disableLoggingForWaiterStateMachine` is true. + * + * @default - no log options + */ + readonly logOptionsForWaiterStateMachine?: LogOptions; + + /** + * Disable logging for `WaiterStateMachine` with `isCompleteHandler` in the provider. + * + * This must not be used if `isCompleteHandler` is not specified. + * + * @default false + */ + readonly disableLoggingForWaiterStateMachine?: boolean; } /** @@ -162,9 +181,17 @@ export class Provider extends Construct implements ICustomResourceProvider { constructor(scope: Construct, id: string, props: ProviderProps) { super(scope, id); - if (!props.isCompleteHandler && (props.queryInterval || props.totalTimeout)) { - throw new Error('"queryInterval" and "totalTimeout" can only be configured if "isCompleteHandler" is specified. ' - + 'Otherwise, they have no meaning'); + if (!props.isCompleteHandler) { + if ( + props.queryInterval + || props.totalTimeout + || props.logOptionsForWaiterStateMachine + || props.disableLoggingForWaiterStateMachine !== undefined + ) { + throw new Error('"queryInterval" and "totalTimeout" and "logOptionsForWaiterStateMachine" and "disableLoggingForWaiterStateMachine" ' + + 'can only be configured if "isCompleteHandler" is specified. ' + + 'Otherwise, they have no meaning'); + } } this.onEventHandler = props.onEventHandler; @@ -181,6 +208,10 @@ export class Provider extends Construct implements ICustomResourceProvider { const onEventFunction = this.createFunction(consts.FRAMEWORK_ON_EVENT_HANDLER_NAME, props.providerFunctionName); if (this.isCompleteHandler) { + if (props.disableLoggingForWaiterStateMachine && props.logOptionsForWaiterStateMachine) { + throw new Error('logOptionsForWaiterStateMachine must not be used if disableLoggingForWaiterStateMachine is true'); + } + const isCompleteFunction = this.createFunction(consts.FRAMEWORK_IS_COMPLETE_HANDLER_NAME); const timeoutFunction = this.createFunction(consts.FRAMEWORK_ON_TIMEOUT_HANDLER_NAME); @@ -191,6 +222,8 @@ export class Provider extends Construct implements ICustomResourceProvider { backoffRate: retry.backoffRate, interval: retry.interval, maxAttempts: retry.maxAttempts, + logOptions: !props.disableLoggingForWaiterStateMachine ? props.logOptionsForWaiterStateMachine : undefined, + disableLogging: props.disableLoggingForWaiterStateMachine, }); // the on-event entrypoint is going to start the execution of the waiter onEventFunction.addEnvironment(consts.WAITER_STATE_MACHINE_ARN_ENV, waiterStateMachine.stateMachineArn); diff --git a/packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts b/packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts index 7f9f4ab728a6e..0490ffc596c93 100644 --- a/packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts +++ b/packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts @@ -1,7 +1,34 @@ import { Construct } from 'constructs'; -import { Grant, IGrantable, Role, ServicePrincipal } from '../../../aws-iam'; +import { Grant, IGrantable, PolicyStatement, Role, ServicePrincipal } from '../../../aws-iam'; import { IFunction } from '../../../aws-lambda'; import { CfnResource, Duration, Stack } from '../../../core'; +import { LogGroup, RetentionDays } from '../../../aws-logs'; +import { LogLevel } from '../../../aws-stepfunctions'; + +export interface LogOptions { + /** + * Determines whether execution data is included in your log. + * + * @default false + */ + readonly includeExecutionData?: boolean; + + /** + * Defines which category of execution history events are logged. + * + * @default ERROR + */ + readonly level?: LogLevel; + + /** + * The number of days framework log events are kept in CloudWatch Logs. When + * updating this property, unsetting it doesn't remove the log retention policy. + * To remove the retention policy, set the value to `INFINITE`. + * + * @default logs.RetentionDays.INFINITE + */ + readonly logRetention?: RetentionDays; +} export interface WaiterStateMachineProps { /** @@ -28,6 +55,22 @@ export interface WaiterStateMachineProps { * Backoff between attempts. */ readonly backoffRate: number; + + /** + * Options for StateMachine logging. + * + * If `disableLogging` is true, this property is ignored. + * + * @default - no logOptions + */ + readonly logOptions?: LogOptions; + + /** + * Disable StateMachine logging. + * + * @default false + */ + readonly disableLogging?: boolean; } /** @@ -49,6 +92,32 @@ export class WaiterStateMachine extends Construct { props.isCompleteHandler.grantInvoke(role); props.timeoutHandler.grantInvoke(role); + let logGroup: LogGroup | undefined; + if (props.disableLogging) { + if (props.logOptions) { + throw new Error('logOptions must not be used if disableLogging is true'); + } + } else { + logGroup = new LogGroup(this, 'LogGroup', { + retention: props.logOptions?.logRetention, + }); + role.addToPrincipalPolicy(new PolicyStatement({ + actions: [ + 'logs:CreateLogDelivery', + 'logs:CreateLogStream', + 'logs:GetLogDelivery', + 'logs:UpdateLogDelivery', + 'logs:DeleteLogDelivery', + 'logs:ListLogDeliveries', + 'logs:PutLogEvents', + 'logs:PutResourcePolicy', + 'logs:DescribeResourcePolicies', + 'logs:DescribeLogGroups', + ], + resources: ['*'], + })); + } + const definition = Stack.of(this).toJsonString({ StartAt: 'framework-isComplete-task', States: { @@ -75,11 +144,24 @@ export class WaiterStateMachine extends Construct { }, }); + const logOptions = logGroup ? { + LoggingConfiguration: { + Destinations: [{ + CloudWatchLogsLogGroup: { + LogGroupArn: logGroup.logGroupArn, + }, + }], + }, + IncludeExecutionData: props.logOptions?.includeExecutionData ?? false, + Level: props.logOptions?.level ?? LogLevel.ERROR, + } : undefined; + const resource = new CfnResource(this, 'Resource', { type: 'AWS::StepFunctions::StateMachine', properties: { DefinitionString: definition, RoleArn: role.roleArn, + ...logOptions, }, }); resource.node.addDependency(role); diff --git a/packages/aws-cdk-lib/custom-resources/test/provider-framework/provider.test.ts b/packages/aws-cdk-lib/custom-resources/test/provider-framework/provider.test.ts index 3c4989f4c567b..e2441dfe80c04 100644 --- a/packages/aws-cdk-lib/custom-resources/test/provider-framework/provider.test.ts +++ b/packages/aws-cdk-lib/custom-resources/test/provider-framework/provider.test.ts @@ -4,6 +4,7 @@ import * as iam from '../../../aws-iam'; import * as kms from '../../../aws-kms'; import * as lambda from '../../../aws-lambda'; import * as logs from '../../../aws-logs'; +import { LogLevel } from '../../../aws-stepfunctions'; import { Duration, Stack } from '../../../core'; import * as cr from '../../lib'; import * as util from '../../lib/provider-framework/util'; @@ -180,6 +181,12 @@ test('if isComplete is specified, the isComplete framework handler is also inclu new cr.Provider(stack, 'MyProvider', { onEventHandler: handler, isCompleteHandler: handler, + logOptionsForWaiterStateMachine: { + includeExecutionData: true, + level: LogLevel.ALL, + logRetention: logs.RetentionDays.ONE_DAY, + }, + disableLoggingForWaiterStateMachine: false, }); // THEN @@ -238,10 +245,51 @@ test('if isComplete is specified, the isComplete framework handler is also inclu ], ], }, + LoggingConfiguration: { + Destinations: [ + { + CloudWatchLogsLogGroup: { + LogGroupArn: { + 'Fn::GetAtt': [ + 'MyProviderwaiterstatemachineLogGroupD43CA868', + 'Arn', + ], + }, + }, + }, + ], + }, + IncludeExecutionData: true, + Level: 'ALL', }); }); -test('fails if "queryInterval" and/or "totalTimeout" are set without "isCompleteHandler"', () => { +test('fails if logOptionsForWaiterStateMachine is specified and disableLoggingForWaiterStateMachine is true', () => { + // GIVEN + const stack = new Stack(); + const handler = new lambda.Function(stack, 'MyHandler', { + code: new lambda.InlineCode('foo'), + handler: 'index.onEvent', + runtime: lambda.Runtime.NODEJS_LATEST, + }); + + // WHEN + // THEN + expect(() => { + new cr.Provider(stack, 'MyProvider', { + onEventHandler: handler, + isCompleteHandler: handler, + logOptionsForWaiterStateMachine: { + includeExecutionData: true, + level: LogLevel.ALL, + logRetention: logs.RetentionDays.ONE_DAY, + }, + disableLoggingForWaiterStateMachine: true, + }); + }).toThrow(/logOptionsForWaiterStateMachine must not be used if disableLoggingForWaiterStateMachine is true/); +}); + +test('fails if "queryInterval" or "totalTimeout" or "logOptionsForWaiterStateMachine" or "disableLoggingForWaiterStateMachine" are set without "isCompleteHandler"', () => { // GIVEN const stack = new Stack(); const handler = new lambda.Function(stack, 'MyHandler', { @@ -254,12 +302,26 @@ test('fails if "queryInterval" and/or "totalTimeout" are set without "isComplete expect(() => new cr.Provider(stack, 'provider1', { onEventHandler: handler, queryInterval: Duration.seconds(10), - })).toThrow(/\"queryInterval\" and \"totalTimeout\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); + })).toThrow(/\"queryInterval\" and \"totalTimeout\" and \"logOptionsForWaiterStateMachine\" and \"disableLoggingForWaiterStateMachine\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); expect(() => new cr.Provider(stack, 'provider2', { onEventHandler: handler, totalTimeout: Duration.seconds(100), - })).toThrow(/\"queryInterval\" and \"totalTimeout\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); + })).toThrow(/\"queryInterval\" and \"totalTimeout\" and \"logOptionsForWaiterStateMachine\" and \"disableLoggingForWaiterStateMachine\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); + + expect(() => new cr.Provider(stack, 'provider3', { + onEventHandler: handler, + logOptionsForWaiterStateMachine: { + includeExecutionData: true, + level: LogLevel.ALL, + logRetention: logs.RetentionDays.ONE_DAY, + }, + })).toThrow(/\"queryInterval\" and \"totalTimeout\" and \"logOptionsForWaiterStateMachine\" and \"disableLoggingForWaiterStateMachine\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); + + expect(() => new cr.Provider(stack, 'provider4', { + onEventHandler: handler, + disableLoggingForWaiterStateMachine: false, + })).toThrow(/\"queryInterval\" and \"totalTimeout\" and \"logOptionsForWaiterStateMachine\" and \"disableLoggingForWaiterStateMachine\" can only be configured if \"isCompleteHandler\" is specified. Otherwise, they have no meaning/); }); describe('retry policy', () => { diff --git a/packages/aws-cdk-lib/custom-resources/test/provider-framework/waiter-state-machine.test.ts b/packages/aws-cdk-lib/custom-resources/test/provider-framework/waiter-state-machine.test.ts index 8ccdab848460c..700de4c3a4362 100644 --- a/packages/aws-cdk-lib/custom-resources/test/provider-framework/waiter-state-machine.test.ts +++ b/packages/aws-cdk-lib/custom-resources/test/provider-framework/waiter-state-machine.test.ts @@ -4,6 +4,8 @@ import * as lambda from '../../../aws-lambda'; import { Code, Function as lambdaFn } from '../../../aws-lambda'; import { Duration, Stack } from '../../../core'; import { WaiterStateMachine } from '../../lib/provider-framework/waiter-state-machine'; +import { LogLevel } from '../../../aws-stepfunctions'; +import { RetentionDays } from '../../../aws-logs'; describe('state machine', () => { test('contains the needed resources', () => { @@ -32,6 +34,11 @@ describe('state machine', () => { backoffRate, interval, maxAttempts, + logOptions: { + includeExecutionData: true, + level: LogLevel.ALL, + logRetention: RetentionDays.ONE_DAY, + }, }); // THEN @@ -54,6 +61,22 @@ describe('state machine', () => { RoleArn: { 'Fn::GetAtt': [roleId, 'Arn'], }, + LoggingConfiguration: { + Destinations: [ + { + CloudWatchLogsLogGroup: { + LogGroupArn: { + 'Fn::GetAtt': [ + 'statemachineLogGroupA08E43E4', + 'Arn', + ], + }, + }, + }, + ], + }, + IncludeExecutionData: true, + Level: 'ALL', }); Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { AssumeRolePolicyDocument: { @@ -91,10 +114,119 @@ describe('state machine', () => { Effect: 'Allow', Resource: stack.resolve(timeoutHandler.resourceArnsForGrantInvoke), }, + { + Action: [ + 'logs:CreateLogDelivery', + 'logs:CreateLogStream', + 'logs:GetLogDelivery', + 'logs:UpdateLogDelivery', + 'logs:DeleteLogDelivery', + 'logs:ListLogDeliveries', + 'logs:PutLogEvents', + 'logs:PutResourcePolicy', + 'logs:DescribeResourcePolicies', + 'logs:DescribeLogGroups', + ], + Effect: 'Allow', + Resource: '*', + }, ], Version: '2012-10-17', }, Roles: [{ Ref: roleId }], }); + Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { + RetentionInDays: 1, + }); + }); + + test('disable logging', () => { + // GIVEN + const stack = new Stack(); + Node.of(stack).setContext('@aws-cdk/core:target-partitions', ['aws', 'aws-cn']); + + const isCompleteHandler = new lambdaFn(stack, 'isComplete', { + code: Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + }); + const timeoutHandler = new lambdaFn(stack, 'isTimeout', { + code: Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + }); + const interval = Duration.hours(2); + const maxAttempts = 2; + const backoffRate = 5; + + // WHEN + new WaiterStateMachine(stack, 'statemachine', { + isCompleteHandler, + timeoutHandler, + backoffRate, + interval, + maxAttempts, + disableLogging: true, + }); + + // THEN + const roleId = 'statemachineRole52044F93'; + Template.fromStack(stack).resourceCountIs('AWS::Logs::LogGroup', 0); + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'lambda:InvokeFunction', + Effect: 'Allow', + Resource: stack.resolve(isCompleteHandler.resourceArnsForGrantInvoke), + }, + { + Action: 'lambda:InvokeFunction', + Effect: 'Allow', + Resource: stack.resolve(timeoutHandler.resourceArnsForGrantInvoke), + }, + ], + Version: '2012-10-17', + }, + Roles: [{ Ref: roleId }], + }); + }); + + test('fails if logOptions is specified and disableLogging is true', () => { + // GIVEN + const stack = new Stack(); + Node.of(stack).setContext('@aws-cdk/core:target-partitions', ['aws', 'aws-cn']); + + const isCompleteHandler = new lambdaFn(stack, 'isComplete', { + code: Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + }); + const timeoutHandler = new lambdaFn(stack, 'isTimeout', { + code: Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + }); + const interval = Duration.hours(2); + const maxAttempts = 2; + const backoffRate = 5; + + // WHEN + // THEN + expect(() => { + new WaiterStateMachine(stack, 'statemachine', { + isCompleteHandler, + timeoutHandler, + backoffRate, + interval, + maxAttempts, + logOptions: { + includeExecutionData: true, + level: LogLevel.ALL, + logRetention: RetentionDays.ONE_DAY, + }, + disableLogging: true, + }); + }).toThrow(/logOptions must not be used if disableLogging is true/); }); });