diff --git a/packages/aws-cdk-lib/aws-lambda/lib/function.ts b/packages/aws-cdk-lib/aws-lambda/lib/function.ts index 49474a936b89c..74e1e34b9dc02 100644 --- a/packages/aws-cdk-lib/aws-lambda/lib/function.ts +++ b/packages/aws-cdk-lib/aws-lambda/lib/function.ts @@ -310,6 +310,7 @@ export interface FunctionOptions extends EventInvokeConfigOptions { * this property, unsetting it doesn't remove the log retention policy. To * remove the retention policy, set the value to `INFINITE`. * + * @deprecated Use `logGroup` instead * @default logs.RetentionDays.INFINITE */ readonly logRetention?: logs.RetentionDays; @@ -318,6 +319,7 @@ export interface FunctionOptions extends EventInvokeConfigOptions { * The IAM role for the Lambda function associated with the custom resource * that sets the retention policy. * + * @deprecated Use `logGroup` instead * @default - A new role is created. */ readonly logRetentionRole?: iam.IRole; @@ -326,6 +328,7 @@ export interface FunctionOptions extends EventInvokeConfigOptions { * When log retention is specified, a custom resource attempts to create the CloudWatch log group. * These options control the retry policy when interacting with CloudWatch APIs. * + * @deprecated Use `logGroup` instead * @default - Default AWS SDK retry options. */ readonly logRetentionRetryOptions?: LogRetentionRetryOptions; @@ -385,6 +388,13 @@ export interface FunctionOptions extends EventInvokeConfigOptions { * @default Auto */ readonly runtimeManagementMode?: RuntimeManagementMode; + + /** + * Configure the log group of the lambda function + * + * @default - Use service defaults + */ + readonly logGroupProps?: logs.BaseLogGroupProps; } export interface FunctionProps extends FunctionOptions { @@ -680,6 +690,7 @@ export class Function extends FunctionBase { public readonly _layers: ILayerVersion[] = []; private _logGroup?: logs.ILogGroup; + private _logGroupProps?: logs.LogGroupProps; /** * Environment variables for this function @@ -875,7 +886,18 @@ export class Function extends FunctionBase { this.addEventSource(event); } - // Log retention + // Can use only one log group implementation + if (props.logGroupProps && props.logRetention) { + throw new Error('Only one of "logGroupProps" or "logRetention" is allowed, but not both. Prefer to use "logGroupProps".'); + } + + // Log Group + this._logGroupProps = props.logGroupProps; + if (this._logGroupProps) { + this.logGroup; + } + + // Log retention @deprecated if (props.logRetention) { const logRetention = new logs.LogRetention(this, 'LogRetention', { logGroupName: `/aws/lambda/${this.functionName}`, @@ -1100,11 +1122,24 @@ export class Function extends FunctionBase { */ public get logGroup(): logs.ILogGroup { if (!this._logGroup) { - const logRetention = new logs.LogRetention(this, 'LogRetention', { - logGroupName: `/aws/lambda/${this.functionName}`, - retention: logs.RetentionDays.INFINITE, + const managedLogGroup = new logs.ServiceManagedLogGroup(this, 'LogGroup', this._logGroupProps); + managedLogGroup.bind({ + parent: this, + logGroupArn: Stack.of(this).formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: `/aws/lambda/${this.functionName}`, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'lambda', + action: 'ListTags', + requestField: 'Resource', + responseField: 'Tags', + }, }); - this._logGroup = logs.LogGroup.fromLogGroupArn(this, `${this.node.id}-LogGroup`, logRetention.logGroupArn); + + this._logGroup = managedLogGroup; } return this._logGroup; } diff --git a/packages/aws-cdk-lib/aws-logs/lib/data-protection-policy.ts b/packages/aws-cdk-lib/aws-logs/lib/data-protection-policy.ts index a04fdd64c42ff..5b3802e3941d5 100644 --- a/packages/aws-cdk-lib/aws-logs/lib/data-protection-policy.ts +++ b/packages/aws-cdk-lib/aws-logs/lib/data-protection-policy.ts @@ -19,7 +19,7 @@ export class DataProtectionPolicy { /** * @internal */ - public _bind(_scope: Construct): DataProtectionPolicyConfig { + public _bind(scope: Construct): DataProtectionPolicyConfig { const name = this.dataProtectionPolicyProps.name || 'data-protection-policy-cdk'; const description = this.dataProtectionPolicyProps.description || 'cdk generated data protection policy'; const version = '2021-06-01'; @@ -45,7 +45,7 @@ export class DataProtectionPolicy { const identifierArns: string[] = []; for (let identifier of this.dataProtectionPolicyProps.identifiers) { - identifierArns.push(Stack.of(_scope).formatArn({ + identifierArns.push(Stack.of(scope).formatArn({ resource: 'data-identifier', region: '', account: 'aws', diff --git a/packages/aws-cdk-lib/aws-logs/lib/index.ts b/packages/aws-cdk-lib/aws-logs/lib/index.ts index 71f2717cc4447..8d964f0ca63af 100644 --- a/packages/aws-cdk-lib/aws-logs/lib/index.ts +++ b/packages/aws-cdk-lib/aws-logs/lib/index.ts @@ -1,4 +1,5 @@ export * from './cross-account-destination'; +export * from './log-group-base'; export * from './log-group'; export * from './log-stream'; export * from './metric-filter'; @@ -8,6 +9,7 @@ export * from './log-retention'; export * from './policy'; export * from './query-definition'; export * from './data-protection-policy'; +export * from './service-managed-log-group'; // AWS::Logs CloudFormation Resources: export * from './logs.generated'; diff --git a/packages/aws-cdk-lib/aws-logs/lib/log-group-base.ts b/packages/aws-cdk-lib/aws-logs/lib/log-group-base.ts new file mode 100644 index 0000000000000..edd7adb7b5c07 --- /dev/null +++ b/packages/aws-cdk-lib/aws-logs/lib/log-group-base.ts @@ -0,0 +1,171 @@ + +import { ILogGroup, MetricFilterOptions, StreamOptions, SubscriptionFilterOptions } from './log-group'; +import { LogStream } from './log-stream'; +import { MetricFilter } from './metric-filter'; +import { FilterPattern } from './pattern'; +import { ResourcePolicy } from './policy'; +import { SubscriptionFilter } from './subscription-filter'; +import * as cloudwatch from '../../aws-cloudwatch'; +import * as iam from '../../aws-iam'; +import { Arn, ArnFormat, Resource } from '../../core'; + +/** + * A CloudWatch Log Group + */ +export abstract class LogGroupBase extends Resource implements ILogGroup { + /** + * The ARN of this log group, with ':*' appended + */ + public abstract readonly logGroupArn: string; + + /** + * The name of this log group + */ + public abstract readonly logGroupName: string; + + private policy?: ResourcePolicy; + + /** + * Create a new Log Stream for this Log Group + * + * @param id Unique identifier for the construct in its parent + * @param props Properties for creating the LogStream + */ + public addStream(id: string, props: StreamOptions = {}): LogStream { + return new LogStream(this, id, { + logGroup: this, + ...props, + }); + } + + /** + * Create a new Subscription Filter on this Log Group + * + * @param id Unique identifier for the construct in its parent + * @param props Properties for creating the SubscriptionFilter + */ + public addSubscriptionFilter(id: string, props: SubscriptionFilterOptions): SubscriptionFilter { + return new SubscriptionFilter(this, id, { + logGroup: this, + ...props, + }); + } + + /** + * Create a new Metric Filter on this Log Group + * + * @param id Unique identifier for the construct in its parent + * @param props Properties for creating the MetricFilter + */ + public addMetricFilter(id: string, props: MetricFilterOptions): MetricFilter { + return new MetricFilter(this, id, { + logGroup: this, + ...props, + }); + } + + /** + * Extract a metric from structured log events in the LogGroup + * + * Creates a MetricFilter on this LogGroup that will extract the value + * of the indicated JSON field in all records where it occurs. + * + * The metric will be available in CloudWatch Metrics under the + * indicated namespace and name. + * + * @param jsonField JSON field to extract (example: '$.myfield') + * @param metricNamespace Namespace to emit the metric under + * @param metricName Name to emit the metric under + * @returns A Metric object representing the extracted metric + */ + public extractMetric(jsonField: string, metricNamespace: string, metricName: string) { + new MetricFilter(this, `${metricNamespace}_${metricName}`, { + logGroup: this, + metricNamespace, + metricName, + filterPattern: FilterPattern.exists(jsonField), + metricValue: jsonField, + }); + + return new cloudwatch.Metric({ metricName, namespace: metricNamespace }).attachTo(this); + } + + /** + * Give permissions to create and write to streams in this log group + */ + public grantWrite(grantee: iam.IGrantable) { + return this.grant(grantee, 'logs:CreateLogStream', 'logs:PutLogEvents'); + } + + /** + * Give permissions to read and filter events from this log group + */ + public grantRead(grantee: iam.IGrantable) { + return this.grant(grantee, + 'logs:FilterLogEvents', + 'logs:GetLogEvents', + 'logs:GetLogGroupFields', + 'logs:DescribeLogGroups', + 'logs:DescribeLogStreams', + ); + } + + /** + * Give the indicated permissions on this log group and all streams + */ + public grant(grantee: iam.IGrantable, ...actions: string[]) { + return iam.Grant.addToPrincipalOrResource({ + grantee, + actions, + // A LogGroup ARN out of CloudFormation already includes a ':*' at the end to include the log streams under the group. + // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html#w2ab1c21c10c63c43c11 + resourceArns: [this.logGroupArn], + resource: this, + }); + } + + /** + * Public method to get the physical name of this log group + * @returns Physical name of log group + */ + public logGroupPhysicalName(): string { + return this.physicalName; + } + + /** + * Adds a statement to the resource policy associated with this log group. + * A resource policy will be automatically created upon the first call to `addToResourcePolicy`. + * + * Any ARN Principals inside of the statement will be converted into AWS Account ID strings + * because CloudWatch Logs Resource Policies do not accept ARN principals. + * + * @param statement The policy statement to add + */ + public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { + if (!this.policy) { + this.policy = new ResourcePolicy(this, 'Policy'); + } + this.policy.document.addStatements(statement.copy({ + principals: statement.principals.map(p => this.convertArnPrincipalToAccountId(p)), + })); + return { statementAdded: true, policyDependable: this.policy }; + } + + private convertArnPrincipalToAccountId(principal: iam.IPrincipal) { + if (principal.principalAccount) { + // we use ArnPrincipal here because the constructor inserts the argument + // into the template without mutating it, which means that there is no + // ARN created by this call. + return new iam.ArnPrincipal(principal.principalAccount); + } + + if (principal instanceof iam.ArnPrincipal) { + const parsedArn = Arn.split(principal.arn, ArnFormat.SLASH_RESOURCE_NAME); + if (parsedArn.account) { + return new iam.ArnPrincipal(parsedArn.account); + } + } + + return principal; + } +} diff --git a/packages/aws-cdk-lib/aws-logs/lib/log-group.ts b/packages/aws-cdk-lib/aws-logs/lib/log-group.ts index f578d1234abd2..683af578cb9a2 100644 --- a/packages/aws-cdk-lib/aws-logs/lib/log-group.ts +++ b/packages/aws-cdk-lib/aws-logs/lib/log-group.ts @@ -1,15 +1,16 @@ import { Construct } from 'constructs'; import { DataProtectionPolicy } from './data-protection-policy'; +import { LogGroupBase } from './log-group-base'; import { LogStream } from './log-stream'; import { CfnLogGroup } from './logs.generated'; import { MetricFilter } from './metric-filter'; -import { FilterPattern, IFilterPattern } from './pattern'; -import { ResourcePolicy } from './policy'; +import { IFilterPattern } from './pattern'; +import { validateLogGroupRetention } from './private/util'; import { ILogSubscriptionDestination, SubscriptionFilter } from './subscription-filter'; import * as cloudwatch from '../../aws-cloudwatch'; import * as iam from '../../aws-iam'; import * as kms from '../../aws-kms'; -import { Arn, ArnFormat, RemovalPolicy, Resource, Stack, Token } from '../../core'; +import { ArnFormat, RemovalPolicy, Stack } from '../../core'; export interface ILogGroup extends iam.IResourceWithPolicy { /** @@ -86,167 +87,6 @@ export interface ILogGroup extends iam.IResourceWithPolicy { logGroupPhysicalName(): string; } -/** - * An CloudWatch Log Group - */ -abstract class LogGroupBase extends Resource implements ILogGroup { - /** - * The ARN of this log group, with ':*' appended - */ - public abstract readonly logGroupArn: string; - - /** - * The name of this log group - */ - public abstract readonly logGroupName: string; - - private policy?: ResourcePolicy; - - /** - * Create a new Log Stream for this Log Group - * - * @param id Unique identifier for the construct in its parent - * @param props Properties for creating the LogStream - */ - public addStream(id: string, props: StreamOptions = {}): LogStream { - return new LogStream(this, id, { - logGroup: this, - ...props, - }); - } - - /** - * Create a new Subscription Filter on this Log Group - * - * @param id Unique identifier for the construct in its parent - * @param props Properties for creating the SubscriptionFilter - */ - public addSubscriptionFilter(id: string, props: SubscriptionFilterOptions): SubscriptionFilter { - return new SubscriptionFilter(this, id, { - logGroup: this, - ...props, - }); - } - - /** - * Create a new Metric Filter on this Log Group - * - * @param id Unique identifier for the construct in its parent - * @param props Properties for creating the MetricFilter - */ - public addMetricFilter(id: string, props: MetricFilterOptions): MetricFilter { - return new MetricFilter(this, id, { - logGroup: this, - ...props, - }); - } - - /** - * Extract a metric from structured log events in the LogGroup - * - * Creates a MetricFilter on this LogGroup that will extract the value - * of the indicated JSON field in all records where it occurs. - * - * The metric will be available in CloudWatch Metrics under the - * indicated namespace and name. - * - * @param jsonField JSON field to extract (example: '$.myfield') - * @param metricNamespace Namespace to emit the metric under - * @param metricName Name to emit the metric under - * @returns A Metric object representing the extracted metric - */ - public extractMetric(jsonField: string, metricNamespace: string, metricName: string) { - new MetricFilter(this, `${metricNamespace}_${metricName}`, { - logGroup: this, - metricNamespace, - metricName, - filterPattern: FilterPattern.exists(jsonField), - metricValue: jsonField, - }); - - return new cloudwatch.Metric({ metricName, namespace: metricNamespace }).attachTo(this); - } - - /** - * Give permissions to create and write to streams in this log group - */ - public grantWrite(grantee: iam.IGrantable) { - return this.grant(grantee, 'logs:CreateLogStream', 'logs:PutLogEvents'); - } - - /** - * Give permissions to read and filter events from this log group - */ - public grantRead(grantee: iam.IGrantable) { - return this.grant(grantee, - 'logs:FilterLogEvents', - 'logs:GetLogEvents', - 'logs:GetLogGroupFields', - 'logs:DescribeLogGroups', - 'logs:DescribeLogStreams', - ); - } - - /** - * Give the indicated permissions on this log group and all streams - */ - public grant(grantee: iam.IGrantable, ...actions: string[]) { - return iam.Grant.addToPrincipalOrResource({ - grantee, - actions, - // A LogGroup ARN out of CloudFormation already includes a ':*' at the end to include the log streams under the group. - // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html#w2ab1c21c10c63c43c11 - resourceArns: [this.logGroupArn], - resource: this, - }); - } - - /** - * Public method to get the physical name of this log group - * @returns Physical name of log group - */ - public logGroupPhysicalName(): string { - return this.physicalName; - } - - /** - * Adds a statement to the resource policy associated with this log group. - * A resource policy will be automatically created upon the first call to `addToResourcePolicy`. - * - * Any ARN Principals inside of the statement will be converted into AWS Account ID strings - * because CloudWatch Logs Resource Policies do not accept ARN principals. - * - * @param statement The policy statement to add - */ - public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { - if (!this.policy) { - this.policy = new ResourcePolicy(this, 'Policy'); - } - this.policy.document.addStatements(statement.copy({ - principals: statement.principals.map(p => this.convertArnPrincpalToAccountId(p)), - })); - return { statementAdded: true, policyDependable: this.policy }; - } - - private convertArnPrincpalToAccountId(principal: iam.IPrincipal) { - if (principal.principalAccount) { - // we use ArnPrincipal here because the constructor inserts the argument - // into the template without mutating it, which means that there is no - // ARN created by this call. - return new iam.ArnPrincipal(principal.principalAccount); - } - - if (principal instanceof iam.ArnPrincipal) { - const parsedArn = Arn.split(principal.arn, ArnFormat.SLASH_RESOURCE_NAME); - if (parsedArn.account) { - return new iam.ArnPrincipal(parsedArn.account); - } - } - - return principal; - } -} - /** * How long, in days, the log contents will be retained. */ @@ -368,23 +208,16 @@ export enum RetentionDays { } /** - * Properties for a LogGroup + * Base properties for LogGroups */ -export interface LogGroupProps { +export interface BaseLogGroupProps { /** * The KMS customer managed key to encrypt the log group with. * - * @default Server-side encrpytion managed by the CloudWatch Logs service + * @default Server-side encryption managed by the CloudWatch Logs service */ readonly encryptionKey?: kms.IKey; - /** - * Name of the log group. - * - * @default Automatically generated - */ - readonly logGroupName?: string; - /** * Data Protection Policy for this log group. * @@ -414,6 +247,18 @@ export interface LogGroupProps { readonly removalPolicy?: RemovalPolicy; } +/** + * Properties for a LogGroup + */ +export interface LogGroupProps extends BaseLogGroupProps { + /** + * Name of the log group. + * + * @default Automatically generated + */ + readonly logGroupName?: string; +} + /** * Define a CloudWatch Log Group */ @@ -468,13 +313,7 @@ export class LogGroup extends LogGroupBase { physicalName: props.logGroupName, }); - let retentionInDays = props.retention; - if (retentionInDays === undefined) { retentionInDays = RetentionDays.TWO_YEARS; } - if (retentionInDays === Infinity || retentionInDays === RetentionDays.INFINITE) { retentionInDays = undefined; } - - if (retentionInDays !== undefined && !Token.isUnresolved(retentionInDays) && retentionInDays <= 0) { - throw new Error(`retentionInDays must be positive, got ${retentionInDays}`); - } + const retentionInDays = validateLogGroupRetention(props.retention); const resource = new CfnLogGroup(this, 'Resource', { kmsKeyId: props.encryptionKey?.keyArn, diff --git a/packages/aws-cdk-lib/aws-logs/lib/private/util.ts b/packages/aws-cdk-lib/aws-logs/lib/private/util.ts new file mode 100644 index 0000000000000..ac4a0a311494c --- /dev/null +++ b/packages/aws-cdk-lib/aws-logs/lib/private/util.ts @@ -0,0 +1,16 @@ +import * as cdk from '../../../core'; +import { RetentionDays } from '../log-group'; + +/** + * Validate log retention + */ +export function validateLogGroupRetention(retentionInDays?: RetentionDays): RetentionDays | undefined { + if (retentionInDays === undefined) { retentionInDays = RetentionDays.TWO_YEARS; } + if (retentionInDays === Infinity || retentionInDays === RetentionDays.INFINITE) { retentionInDays = undefined; } + + if (retentionInDays !== undefined && !cdk.Token.isUnresolved(retentionInDays) && retentionInDays <= 0) { + throw new Error(`retentionInDays must be positive, got ${retentionInDays}`); + } + + return retentionInDays; +} diff --git a/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group-provider/.is_custom_resource b/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group-provider/.is_custom_resource new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group-provider/index.ts b/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group-provider/index.ts new file mode 100644 index 0000000000000..c3d66af3e1c6b --- /dev/null +++ b/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group-provider/index.ts @@ -0,0 +1,231 @@ +/* eslint-disable no-console */ +// eslint-disable-next-line import/no-extraneous-dependencies +import * as Logs from '@aws-sdk/client-cloudwatch-logs'; +import type { ServiceManagedLogGroupTaggingConfig } from '../service-managed-log-group'; + +interface LogRetentionEvent extends Omit { + ResourceProperties: { + ServiceToken: string; + LogGroupName: string; + LogGroupRegion?: string; + RetentionInDays?: string; + SdkRetry?: { + maxRetries?: string; + }; + RemovalPolicy?: string + Tagging: ServiceManagedLogGroupTaggingConfig, + }; +} + +/** + * Creates a log group and doesn't throw if it exists. + */ +async function createLogGroupSafe(logGroupName: string, client: Logs.CloudWatchLogsClient, withDelay: (block: () => Promise) => Promise) { + await withDelay(async () => { + try { + const params = { logGroupName }; + const command = new Logs.CreateLogGroupCommand(params); + await client.send(command); + + } catch (error: any) { + if (error instanceof Logs.ResourceAlreadyExistsException || error.name === 'ResourceAlreadyExistsException') { + // The log group is already created by the lambda execution + return; + } + + throw error; + } + }); +} + +/** + * Deletes a log group and doesn't throw if it does not exist. + */ +async function deleteLogGroup(logGroupName: string, client: Logs.CloudWatchLogsClient, withDelay: (block: () => Promise) => Promise) { + await withDelay(async () => { + try { + const params = { logGroupName }; + const command = new Logs.DeleteLogGroupCommand(params); + await client.send(command); + + } catch (error: any) { + if (error instanceof Logs.ResourceNotFoundException || error.name === 'ResourceNotFoundException') { + // The log group doesn't exist + return; + } + + throw error; + } + }); +} + +/** + * Puts or deletes a retention policy on a log group. + */ +async function setRetentionPolicy( + logGroupName: string, + client: Logs.CloudWatchLogsClient, + withDelay: (block: () => Promise) => Promise, + retentionInDays?: number, +) { + + await withDelay(async () => { + if (!retentionInDays) { + const params = { logGroupName }; + const deleteCommand = new Logs.DeleteRetentionPolicyCommand(params); + await client.send(deleteCommand); + } else { + const params = { logGroupName, retentionInDays }; + const putCommand = new Logs.PutRetentionPolicyCommand(params); + await client.send(putCommand); + } + }); +} + +export async function handler(event: LogRetentionEvent, context: AWSLambda.Context) { + try { + console.log(JSON.stringify({ ...event, ResponseURL: '...' })); + + // The target log group + const logGroupName = event.ResourceProperties.LogGroupName; + + // The region of the target log group + const logGroupRegion = event.ResourceProperties.LogGroupRegion; + + // Parse to AWS SDK retry options + const maxRetries = parseIntOptional(event.ResourceProperties.SdkRetry?.maxRetries) ?? 5; + const withDelay = makeWithDelay(maxRetries); + + const sdkConfig: Logs.CloudWatchLogsClientConfig = { + logger: console, + region: logGroupRegion, + maxAttempts: Math.max(5, maxRetries), // Use a minimum for SDK level retries, because it might include retryable failures that withDelay isn't checking for + }; + const client = new Logs.CloudWatchLogsClient(sdkConfig); + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + // Act on the target log group + await createLogGroupSafe(logGroupName, client, withDelay); + await setRetentionPolicy(logGroupName, client, withDelay, parseIntOptional(event.ResourceProperties.RetentionInDays)); + + // Configure the Log Group for the Custom Resource function itself + if (event.RequestType === 'Create') { + const clientForCustomResourceFunction = new Logs.CloudWatchLogsClient({ + logger: console, + region: process.env.AWS_REGION, + }); + // Set a retention policy of 1 day on the logs of this very function. + // Due to the async nature of the log group creation, the log group for this function might + // still be not created yet at this point. Therefore we attempt to create it. + // In case it is being created, createLogGroupSafe will handle the conflict. + await createLogGroupSafe(`/aws/lambda/${context.functionName}`, clientForCustomResourceFunction, withDelay); + // If createLogGroupSafe fails, the log group is not created even after multiple attempts. + // In this case we have nothing to set the retention policy on but an exception will skip + // the next line. + await setRetentionPolicy(`/aws/lambda/${context.functionName}`, clientForCustomResourceFunction, withDelay, 1); + } + } + + // When the requestType is delete, delete the log group if the removal policy is delete + if (event.RequestType === 'Delete' && event.ResourceProperties.RemovalPolicy === 'destroy') { + await deleteLogGroup(logGroupName, client, withDelay); + // else retain the log group + } + + await respond('SUCCESS', 'OK', logGroupName); + } catch (e: any) { + console.log(e); + await respond('FAILED', e.message, event.ResourceProperties.LogGroupName); + } + + function respond(responseStatus: string, reason: string, physicalResourceId: string) { + const responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: { + // Add log group name as part of the response so that it's available via Fn::GetAtt + LogGroupName: event.ResourceProperties.LogGroupName, + }, + }); + + console.log('Responding', responseBody); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const parsedUrl = require('url').parse(event.ResponseURL); + const requestOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: 'PUT', + headers: { + 'content-type': '', + 'content-length': Buffer.byteLength(responseBody, 'utf8'), + }, + }; + + return new Promise((resolve, reject) => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const request = require('https').request(requestOptions, resolve); + request.on('error', reject); + request.write(responseBody); + request.end(); + } catch (e) { + reject(e); + } + }); + } +} + +function parseIntOptional(value?: string, base = 10): number | undefined { + if (value === undefined) { + return undefined; + } + + return parseInt(value, base); +} + +function makeWithDelay( + maxRetries: number, + delayBase: number = 100, + delayCap = 10 * 1000, // 10s +): (block: () => Promise) => Promise { + // If we try to update the log group, then due to the async nature of + // Lambda logging there could be a race condition when the same log group is + // already being created by the lambda execution. This can sometime result in + // an error "OperationAbortedException: A conflicting operation is currently + // in progress...Please try again." + // To avoid an error, we do as requested and try again. + + return async (block: () => Promise) => { + let attempts = 0; + do { + try { + return await block(); + } catch (error: any) { + if ( + error instanceof Logs.OperationAbortedException + || error.name === 'OperationAbortedException' + || error.name === 'ThrottlingException' // There is no class to check with instanceof, see https://github.com/aws/aws-sdk-js-v3/issues/5140 + ) { + if (attempts < maxRetries ) { + attempts++; + await new Promise(resolve => setTimeout(resolve, calculateDelay(attempts, delayBase, delayCap))); + continue; + } else { + // The log group is still being changed by another execution but we are out of retries + throw new Error('Out of attempts to change log group'); + } + } + throw error; + } + } while (true); // exit happens on retry count check + }; +} + +function calculateDelay(attempt: number, base: number, cap: number): number { + return Math.round(Math.random() * Math.min(cap, base * 2 ** attempt)); +} diff --git a/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group.ts b/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group.ts new file mode 100644 index 0000000000000..59e472473431d --- /dev/null +++ b/packages/aws-cdk-lib/aws-logs/lib/service-managed-log-group.ts @@ -0,0 +1,272 @@ +import * as path from 'path'; +import { Construct, IConstruct } from 'constructs'; +import { BaseLogGroupProps, ILogGroup } from './log-group'; +import { LogGroupBase } from './log-group-base'; +import { validateLogGroupRetention } from './private/util'; +import * as iam from '../../aws-iam'; +import * as s3_assets from '../../aws-s3-assets'; +import * as cdk from '../../core'; + +const SERVICE_MANAGED_LOG_GROUP_TYPE = 'Custom::ServiceManagedLogGroup'; +const SERVICE_MANAGED_LOG_GROUP_TAG = 'aws-cdk:service-managed-log-group'; + +/** + * Properties for ServiceManagedLogGroup + */ +export interface ServiceManagedLogGroupProps extends BaseLogGroupProps {} + +/** + * Options for binding a ServiceManagedLogGroup to its parent + */ +export interface ServiceManagedLogGroupBindOptions { + /** + * The arn of the log group + */ + readonly logGroupArn: string; + + /** + * The resource owning the log group. + */ + readonly parent: IConstruct; + + /** + * Configuration for tagging the parent resource + */ + readonly tagging: ServiceManagedLogGroupTaggingConfig; +} + +/** + * Tagging config for retrieving tags from the owning resource + */ +export interface ServiceManagedLogGroupTaggingConfig { + /** + * The service managing the log group + */ + readonly service: string; + /** + * The API action used to retrieve tags + */ + readonly action: string; + /** + * The request field in dot notation to pass the resource name + * @default "Resource" + */ + readonly requestField?: string; + /** + * The response field in dot notation that will contain the list of tags + * @default "Tags" + */ + readonly responseField?: string; + /** + * Additional permissions given to the custom resource function to query tags + * @default "[]" + */ + readonly permissions?: string[]; +} + +/** + * A CloudWatch Log Group that is created and managed by another service + * + * With this construct, the Log Group configuration can be changed anyway. + * You must call `bind()` on the ServiceManagedLogGroup, to connect it with the parent resource. + * + * @resource AWS::Logs::LogGroup + */ +export class ServiceManagedLogGroup extends LogGroupBase implements ILogGroup { + public readonly logGroupArn: string; + public readonly logGroupName: string; + + /** + * Tags for the LogGroup. + */ + public readonly tags: cdk.TagManager = new cdk.TagManager(cdk.TagType.KEY_VALUE, 'AWS::Logs::LogGroup'); + + private provider: ServiceManagedLogGroupFunction; + private props: BaseLogGroupProps; + private _logGroupArn?: string; + + constructor(scope: Construct, id: string, props: ServiceManagedLogGroupProps = {}) { + super(scope, id); + + this.props = { + ...props, + retention: validateLogGroupRetention(props.retention), + }; + + this.logGroupArn = cdk.Lazy.string({ + produce: () => this._logGroupArn + ':*', + }); + this.logGroupName = cdk.Lazy.string({ + produce: () => cdk.Stack.of(this).splitArn(this._logGroupArn!, cdk.ArnFormat.COLON_RESOURCE_NAME).resourceName, + }); + + // Custom resource provider + this.provider = this.ensureSingletonProviderFunction(); + } + + /** + * Helper method to ensure that only one instance of the provider function resources is in the stack. + * Mimicking the behavior of @aws-cdk/aws-lambda's SingletonFunction to prevent circular dependencies. + */ + private ensureSingletonProviderFunction() { + const functionLogicalId = 'ServiceManagedLogGroup' + 'f0360f7393ea41069d5f706d30f37fa7'; + const existing = cdk.Stack.of(this).node.tryFindChild(functionLogicalId); + if (existing) { + return existing as ServiceManagedLogGroupFunction; + } + return new ServiceManagedLogGroupFunction(cdk.Stack.of(this), functionLogicalId); + } + + /** + * Bind + */ + public bind(options: ServiceManagedLogGroupBindOptions) { + this._logGroupArn = options.logGroupArn; + + const resource = new cdk.CfnResource(this, 'Resource', { + type: SERVICE_MANAGED_LOG_GROUP_TYPE, + properties: { + ServiceToken: this.provider.functionArn, + LogGroupName: this.logGroupName, + LogGroupRegion: cdk.Lazy.string({ + produce: () => cdk.Stack.of(this).splitArn(this._logGroupArn!, cdk.ArnFormat.COLON_RESOURCE_NAME).region, + }), + DataProtectionPolicy: this.props.dataProtectionPolicy?._bind(this), + KmsKeyId: this.props.encryptionKey?.keyArn, + RetentionInDays: this.props.retention, + Tags: this.tags.renderedTags, + Tagging: options.tagging, + RemovalPolicy: this.props.removalPolicy, + }, + }); + resource.applyRemovalPolicy(this.props.removalPolicy); + + // Grant required permissions to the provider function, depending on used features + if (this.props.encryptionKey) { + this.provider.grantEncryption(this._logGroupArn); + } + if (this.props.dataProtectionPolicy) { + this.provider.grantDataProtectionPolicy(this._logGroupArn); + } + if (this.props.removalPolicy === cdk.RemovalPolicy.DESTROY) { + this.provider.grantDelete(this._logGroupArn); + } + // We don't know ahead of time if tags are going to be set + this.provider.grantTags(this._logGroupArn); + + // We also tag the parent resource to record the fact that we are updating the log group + // The custom resource will check this tag before delete the log group + // Because tagging and untagging will ALWAYS happen before the CR is deleted, + // we can remove the construct, without the deleting the log group as a side effect. + if (cdk.Resource.isOwnedResource(options.parent)) { + cdk.Tags.of(options.parent.node.defaultChild!).add(SERVICE_MANAGED_LOG_GROUP_TAG, 'true'); + } + } +} + +/** + * The lambda function backing the custom resource + */ +class ServiceManagedLogGroupFunction extends Construct implements cdk.ITaggable { + public readonly functionArn: cdk.Reference; + + public readonly tags: cdk.TagManager = new cdk.TagManager(cdk.TagType.KEY_VALUE, 'AWS::Lambda::Function'); + + private readonly role: iam.IRole; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const asset = new s3_assets.Asset(this, 'Code', { + path: path.join(__dirname, 'service-managed-log-group-provider'), + }); + + const role = new iam.Role(this, 'ServiceRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], + }); + + // Special permissions for log retention + // Using '*' here because we will also put a retention policy on + // the log group of the provider function. + // Referencing its name creates a CF circular dependency. + role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['logs:PutRetentionPolicy', 'logs:DeleteRetentionPolicy'], + resources: ['*'], + })); + + this.role = role; + + // Lambda function + const resource = new cdk.CfnResource(this, 'Resource', { + type: 'AWS::Lambda::Function', + properties: { + Handler: 'index.handler', + Runtime: 'nodejs18.x', + Code: { + S3Bucket: asset.s3BucketName, + S3Key: asset.s3ObjectKey, + }, + Role: role.roleArn, + Tags: this.tags.renderedTags, + }, + }); + this.functionArn = resource.getAtt('Arn'); + + asset.addResourceMetadata(resource, 'Code'); + + // Function dependencies + role.node.children.forEach((child) => { + if (cdk.CfnResource.isCfnResource(child)) { + resource.addDependency(child); + } + if (Construct.isConstruct(child) && child.node.defaultChild && cdk.CfnResource.isCfnResource(child.node.defaultChild)) { + resource.addDependency(child.node.defaultChild); + } + }); + } + + /** + * @internal + */ + public grantDataProtectionPolicy(logGroupArn: string) { + this.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['logs:PutDataProtectionPolicy', 'logs:DeleteDataProtectionPolicy'], + resources: [`${logGroupArn}:*`], + })); + } + + /** + * @internal + */ + public grantEncryption(logGroupArn: string) { + this.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['logs:AssociateKmsKey', 'logs:DisassociateKmsKey'], + resources: [`${logGroupArn}:*`], + })); + } + + /** + * @internal + */ + public grantTags(logGroupArn: string) { + this.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: [ + 'logs:ListTagsForResource', + 'logs:TagResource', + 'logs:UntagResource', + ], + resources: [logGroupArn], + })); + } + + /** + * @internal + */ + public grantDelete(logGroupArn: string) { + this.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['logs:DeleteLogGroup'], + resources: [`${logGroupArn}:*`], + })); + } +} diff --git a/packages/aws-cdk-lib/aws-logs/test/service-managed-log-group.test.ts b/packages/aws-cdk-lib/aws-logs/test/service-managed-log-group.test.ts new file mode 100644 index 0000000000000..3e1dd15a99805 --- /dev/null +++ b/packages/aws-cdk-lib/aws-logs/test/service-managed-log-group.test.ts @@ -0,0 +1,503 @@ +import * as path from 'path'; +import { Match, Template } from '../../assertions'; +import * as cdk from '../../core'; +import * as cxapi from '../../cx-api'; +import { ServiceManagedLogGroup, RetentionDays } from '../lib'; + +const FUNCTION_LOGICAL_ID = 'ServiceManagedLogGroup' + 'f0360f7393ea41069d5f706d30f37fa7'; +const MATCH_DEFAULT_POLICY_STATEMENT = [Match.objectLike({}), Match.objectLike({})]; +const MATCH_POLICY_NAME = Match.stringLikeRegexp(FUNCTION_LOGICAL_ID + 'ServiceRoleDefaultPolicy'); +const MATCH_ROLE_REF = { Ref: Match.stringLikeRegexp(FUNCTION_LOGICAL_ID + 'ServiceRole') }; +const MATCH_SERVICE_TOKEN = { + 'Fn::GetAtt': [ + Match.stringLikeRegexp(FUNCTION_LOGICAL_ID + '.{8}'), + 'Arn', + ], +}; +const matchArn = (name = 'group', includeStream = false) => ({ + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + `:log-group:${name}${includeStream ? ':*' : ''}`, + ], + ], +}); +const MATCH_ARN_JOIN_STREAM = matchArn('group', true); +const MATCH_ARN_JOIN = matchArn('group', false); + +describe('service managed log group', () => { + test('log group construct', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_MONTH, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayEquals([ + { + Action: [ + 'logs:PutRetentionPolicy', + 'logs:DeleteRetentionPolicy', + ], + Effect: 'Allow', + Resource: '*', + }, + { + Action: [ + 'logs:ListTagsForResource', + 'logs:TagResource', + 'logs:UntagResource', + ], + Effect: 'Allow', + Resource: MATCH_ARN_JOIN, + }, + ]), + Version: '2012-10-17', + }, + PolicyName: MATCH_POLICY_NAME, + Roles: [MATCH_ROLE_REF], + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Handler: 'index.handler', + Runtime: 'nodejs18.x', + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group', + RetentionInDays: 30, + }); + }); + + test('set the removalPolicy to DESTROY', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + { + Action: 'logs:DeleteLogGroup', + Effect: 'Allow', + Resource: MATCH_ARN_JOIN_STREAM, + }, + ]), + Version: '2012-10-17', + }, + PolicyName: MATCH_POLICY_NAME, + Roles: [MATCH_ROLE_REF], + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + RemovalPolicy: 'destroy', + }); + }); + + test('set the removalPolicy to RETAIN', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: MATCH_DEFAULT_POLICY_STATEMENT, // no delete permissions + Version: '2012-10-17', + }, + PolicyName: MATCH_POLICY_NAME, + Roles: [MATCH_ROLE_REF], + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group', + RetentionInDays: 1, + RemovalPolicy: 'retain', + }); + }); + + describe('multiple log group resources', () => { + test('both removalPolicy DESTROY', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup1 = new ServiceManagedLogGroup(stack, 'MyLogGroup1', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + logGroup1.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group1', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + const logGroup2 = new ServiceManagedLogGroup(stack, 'MyLogGroup2', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + logGroup2.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group2', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + { + Action: 'logs:DeleteLogGroup', + Effect: 'Allow', + Resource: matchArn('group1', true), + }, + { + Action: 'logs:DeleteLogGroup', + Effect: 'Allow', + Resource: matchArn('group2', true), + }, + ]), + Version: '2012-10-17', + }, + PolicyName: MATCH_POLICY_NAME, + Roles: [MATCH_ROLE_REF], + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group1', + RetentionInDays: 1, + RemovalPolicy: 'destroy', + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group2', + RetentionInDays: 1, + RemovalPolicy: 'destroy', + }); + }); + + test('with one removalPolicy DESTROY and one removalPolicy RETAIN', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup1 = new ServiceManagedLogGroup(stack, 'MyLogGroup1', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + logGroup1.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group1', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + const logGroup2 = new ServiceManagedLogGroup(stack, 'MyLogGroup2', { + retention: RetentionDays.ONE_DAY, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + logGroup2.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group2', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + { + Action: 'logs:DeleteLogGroup', + Effect: 'Allow', + Resource: matchArn('group1', true), + }, + ]), + Version: '2012-10-17', + }, + PolicyName: MATCH_POLICY_NAME, + Roles: [MATCH_ROLE_REF], + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group1', + RetentionInDays: 1, + RemovalPolicy: 'destroy', + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group2', + RetentionInDays: 1, + RemovalPolicy: 'retain', + }); + }); + }); + + test('the removalPolicy is not set', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_DAY, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + ServiceToken: MATCH_SERVICE_TOKEN, + LogGroupName: 'group', + RetentionInDays: 1, + RemovalPolicy: Match.absent(), + }); + }); + + test('with RetentionPeriod set to Infinity', () => { + const stack = new cdk.Stack(); + + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.INFINITE, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::ServiceManagedLogGroup', { + RetentionInDays: Match.absent(), + }); + }); + + test('log group ARN is well formed and conforms', () => { + const stack = new cdk.Stack(); + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_MONTH, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + const logGroupArn = stack.resolve(logGroup.logGroupArn); + expect(logGroupArn).toMatchObject({ + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':log-group:group:*', + ], + ], + }); + }); + + test('retention Lambda CfnResource receives propagated tags', () => { + const stack = new cdk.Stack(); + cdk.Tags.of(stack).add('test-key', 'test-value'); + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_MONTH, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Tags: [ + { + Key: 'test-key', + Value: 'test-value', + }, + ], + }); + }); + + test('asset metadata added to log retention construct lambda function', () => { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true); + stack.node.setContext(cxapi.DISABLE_ASSET_STAGING_CONTEXT, true); + + const assetLocation = path.join(__dirname, '../', '/lib', '/service-managed-log-group-provider'); + + // WHEN + const logGroup = new ServiceManagedLogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.ONE_MONTH, + }); + logGroup.bind({ + parent: stack, + logGroupArn: stack.formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: 'group', + arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME, + }), + tagging: { + service: 'logs', + action: 'ListTags', + }, + }); + + // Then + Template.fromStack(stack).hasResource('AWS::Lambda::Function', { + Metadata: { + 'aws:asset:path': assetLocation, + 'aws:asset:is-bundled': false, + 'aws:asset:property': 'Code', + }, + }); + }); +}); diff --git a/packages/aws-cdk-lib/awslint.json b/packages/aws-cdk-lib/awslint.json index e5c25df303d23..b8bb7892384f1 100644 --- a/packages/aws-cdk-lib/awslint.json +++ b/packages/aws-cdk-lib/awslint.json @@ -2779,6 +2779,7 @@ "docs-public-apis:aws-cdk-lib.aws_guardduty.CfnDetector.CFNFeatureConfigurationProperty.additionalConfiguration", "docs-public-apis:aws-cdk-lib.aws_guardduty.CfnDetector.TagItemProperty", "docs-public-apis:aws-cdk-lib.aws_guardduty.CfnDetector.TagItemProperty.key", - "docs-public-apis:aws-cdk-lib.aws_guardduty.CfnDetector.TagItemProperty.value" + "docs-public-apis:aws-cdk-lib.aws_guardduty.CfnDetector.TagItemProperty.value", + "props-physical-name:aws-cdk-lib.aws_logs.ServiceManagedLogGroupProps" ] }