From af8ea227e8e7db5d681408b5099e86095e1957bc Mon Sep 17 00:00:00 2001 From: Niranjan Jayakar Date: Mon, 20 Jan 2020 11:15:12 +0000 Subject: [PATCH] feat(lambda): avail function log group in the CDK The lambda function's log group is now available for use in the CDK app so it can be used further to create subscription filters, metric filters, etc. or in any other way that a regular log group may be used. The implementation uses an underlying CustomResource to guarantee the existence of this log group as part of the CloudFormation stack. A new property called `exposeLogGroup` to enable this, so that existing stacks that have a Lambda function don't change significantly by automatically getting this CustomResource, as well as, users who are not interested in this log group can opt out. closes #3838 --- packages/@aws-cdk/aws-lambda/lib/function.ts | 30 ++++++++- .../lib/log-retention-provider/index.ts | 4 +- .../@aws-cdk/aws-lambda/lib/log-retention.ts | 67 ++++++++++++++++++- .../test/integ.log-retention.expected.json | 18 ++--- .../@aws-cdk/aws-lambda/test/test.function.ts | 27 +++++++- .../test/test.log-retention-provider.ts | 34 +++++++++- .../aws-lambda/test/test.log-retention.ts | 20 +++++- 7 files changed, 182 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index 6b3f9901b93ec..34652b0bea603 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -247,6 +247,17 @@ export interface FunctionProps extends EventInvokeConfigOptions { * @default - A new role is created. */ readonly logRetentionRole?: iam.IRole; + + /** + * Expose the log group of the lambda function via `logGroup` getter. When + * this property is set, the getter will return the Lambda function's log + * group. + * When this property and the `logRetention` property are both unset, the + * getter will throw an exception. + * + * @default false + */ + readonly exposeLogGroup?: boolean; } /** @@ -408,6 +419,8 @@ export class Function extends FunctionBase { private readonly layers: ILayerVersion[] = []; + private _logGroup?: logs.ILogGroup; + /** * Environment variables for this function */ @@ -486,10 +499,11 @@ export class Function extends FunctionBase { } // Log retention - if (props.logRetention) { - new LogRetention(this, 'LogRetention', { + if (props.logRetention || props.exposeLogGroup) { + const retention = props.logRetention || logs.RetentionDays.INFINITE; + this._logGroup = new LogRetention(this, 'LogRetention', { logGroupName: `/aws/lambda/${this.functionName}`, - retention: props.logRetention, + retention, role: props.logRetentionRole }); } @@ -571,6 +585,16 @@ export class Function extends FunctionBase { }); } + /** + * The LogGroup where the Lambda function's logs are made available. + */ + public get logGroup(): logs.ILogGroup { + if (!this._logGroup) { + throw new Error('LogGroup is not available when "exposeLogGroup" property is unset.'); + } + return this._logGroup; + } + private renderEnvironment() { if (!this.environment || Object.keys(this.environment).length === 0) { return undefined; diff --git a/packages/@aws-cdk/aws-lambda/lib/log-retention-provider/index.ts b/packages/@aws-cdk/aws-lambda/lib/log-retention-provider/index.ts index 8ebc6503149cd..d8d4366ab0fd6 100644 --- a/packages/@aws-cdk/aws-lambda/lib/log-retention-provider/index.ts +++ b/packages/@aws-cdk/aws-lambda/lib/log-retention-provider/index.ts @@ -79,7 +79,9 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, - Data: {} + Data: { + LogGroupName: event.ResourceProperties.LogGroupName, + }, }); console.log('Responding', responseBody); diff --git a/packages/@aws-cdk/aws-lambda/lib/log-retention.ts b/packages/@aws-cdk/aws-lambda/lib/log-retention.ts index 546a6afe6a0ff..77b988d3f6cd3 100644 --- a/packages/@aws-cdk/aws-lambda/lib/log-retention.ts +++ b/packages/@aws-cdk/aws-lambda/lib/log-retention.ts @@ -1,3 +1,4 @@ +import * as metrics from '@aws-cdk/aws-cloudwatch'; import * as iam from '@aws-cdk/aws-iam'; import * as logs from '@aws-cdk/aws-logs'; import * as cdk from '@aws-cdk/core'; @@ -33,9 +34,15 @@ export interface LogRetentionProps { * log group. The log group is created if it doesn't already exist. The policy * is removed when `retentionDays` is `undefined` or equal to `Infinity`. */ -export class LogRetention extends cdk.Construct { +export class LogRetention extends cdk.Construct implements logs.ILogGroup { + + public readonly logGroupArn: string; + public readonly logGroupName: string; + public readonly stack: cdk.Stack; + constructor(scope: cdk.Construct, id: string, props: LogRetentionProps) { super(scope, id); + this.stack = cdk.Stack.of(this); // Custom resource provider const provider = new SingletonFunction(this, 'Provider', { @@ -58,7 +65,7 @@ export class LogRetention extends cdk.Construct { // Need to use a CfnResource here to prevent lerna dependency cycles // @aws-cdk/aws-cloudformation -> @aws-cdk/aws-lambda -> @aws-cdk/aws-cloudformation - new cdk.CfnResource(this, 'Resource', { + const resource = new cdk.CfnResource(this, 'Resource', { type: 'Custom::LogRetention', properties: { ServiceToken: provider.functionArn, @@ -66,5 +73,61 @@ export class LogRetention extends cdk.Construct { RetentionInDays: props.retention === Infinity ? undefined : props.retention } }); + + this.logGroupName = resource.getAtt('LogGroupName').toString(); + // Append ':*' at the end of the ARN to match with how CloudFormation does this for LogGroup ARNs + // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html#aws-resource-logs-loggroup-return-values + this.logGroupArn = cdk.Stack.of(this).formatArn({ + service: 'logs', + resource: 'log-group', + resourceName: this.logGroupName, + sep: ':' + }) + ':*'; + } + + public addStream(id: string, props: logs.StreamOptions = {}): logs.LogStream { + return new logs.LogStream(this, id, { + logGroup: this, + ...props + }); + } + + public addSubscriptionFilter(id: string, props: logs.SubscriptionFilterOptions): logs.SubscriptionFilter { + return new logs.SubscriptionFilter(this, id, { + logGroup: this, + ...props + }); + } + + public addMetricFilter(id: string, props: logs.MetricFilterOptions): logs.MetricFilter { + return new logs.MetricFilter(this, id, { + logGroup: this, + ...props + }); + } + + public extractMetric(jsonField: string, metricNamespace: string, metricName: string) { + new logs.MetricFilter(this, `${metricNamespace}_${metricName}`, { + logGroup: this, + metricNamespace, + metricName, + filterPattern: logs.FilterPattern.exists(jsonField), + metricValue: jsonField + }); + + return new metrics.Metric({ metricName, namespace: metricNamespace }).attachTo(this); + } + + public grantWrite(grantee: iam.IGrantable) { + return this.grant(grantee, 'logs:CreateLogStream', 'logs:PutLogEvents'); + } + + public grant(grantee: iam.IGrantable, ...actions: string[]) { + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [this.logGroupArn], + scope: this, + }); } } diff --git a/packages/@aws-cdk/aws-lambda/test/integ.log-retention.expected.json b/packages/@aws-cdk/aws-lambda/test/integ.log-retention.expected.json index 95cf3144afa23..d200c12892620 100644 --- a/packages/@aws-cdk/aws-lambda/test/integ.log-retention.expected.json +++ b/packages/@aws-cdk/aws-lambda/test/integ.log-retention.expected.json @@ -133,7 +133,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3Bucket26B8E770" + "Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3BucketAD409190" }, "S3Key": { "Fn::Join": [ @@ -146,7 +146,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE" + "Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44" } ] } @@ -159,7 +159,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE" + "Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44" } ] } @@ -331,17 +331,17 @@ } }, "Parameters": { - "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3Bucket26B8E770": { + "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3BucketAD409190": { "Type": "String", - "Description": "S3 bucket for asset \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\"" + "Description": "S3 bucket for asset \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\"" }, - "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE": { + "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44": { "Type": "String", - "Description": "S3 key for asset version \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\"" + "Description": "S3 key for asset version \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\"" }, - "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbArtifactHash1C8D5106": { + "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fArtifactHashE9A34B77": { "Type": "String", - "Description": "Artifact hash for asset \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\"" + "Description": "Artifact hash for asset \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/test.function.ts b/packages/@aws-cdk/aws-lambda/test/test.function.ts index 1a74e2adcb9b3..fbda02735033f 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.function.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.function.ts @@ -85,5 +85,30 @@ export = testCase({ code: lambda.Code.fromInline('') }), /Lambda inline code cannot be empty/); test.done(); - } + }, + + 'logGroup getter throws an exception when both logRetention and exposeLogGroup are unset'(test: Test) { + const stack = new cdk.Stack(); + const fn = new lambda.Function(stack, 'fn', { + handler: 'foo', + runtime: lambda.Runtime.NODEJS_10_X, + code: lambda.Code.fromInline('foo'), + }); + test.throws(() => fn.logGroup, /LogGroup is not available/); + test.done(); + }, + + 'logGroup is available when exposeLogGroup is set'(test: Test) { + const stack = new cdk.Stack(); + const fn = new lambda.Function(stack, 'fn', { + handler: 'foo', + runtime: lambda.Runtime.NODEJS_10_X, + code: lambda.Code.fromInline('foo'), + exposeLogGroup: true, + }); + const logGroup = fn.logGroup; + test.ok(logGroup.logGroupName); + test.ok(logGroup.logGroupArn); + test.done(); + }, }); diff --git a/packages/@aws-cdk/aws-lambda/test/test.log-retention-provider.ts b/packages/@aws-cdk/aws-lambda/test/test.log-retention-provider.ts index 89c5330442844..def4167afd554 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.log-retention-provider.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.log-retention-provider.ts @@ -263,5 +263,37 @@ export = { test.equal(request.isDone(), true); test.done(); - } + }, + + async 'response data contains the log group name'(test: Test) { + AWS.mock('CloudWatchLogs', 'createLogGroup', sinon.fake.resolves({})); + AWS.mock('CloudWatchLogs', 'putRetentionPolicy', sinon.fake.resolves({})); + AWS.mock('CloudWatchLogs', 'deleteRetentionPolicy', sinon.fake.resolves({})); + + const event = { + ...eventCommon, + ResourceProperties: { + ServiceToken: 'token', + RetentionInDays: '30', + LogGroupName: 'group' + } + }; + + async function withOperation(operation: string) { + const request = nock('https://localhost') + .put('/', (body: AWSLambda.CloudFormationCustomResourceResponse) => body.Data?.LogGroupName === 'group') + .reply(200); + + const opEvent = { ...event, RequestType: operation }; + await provider.handler(opEvent as AWSLambda.CloudFormationCustomResourceCreateEvent, context); + + test.equal(request.isDone(), true); + } + + await withOperation('Create'); + await withOperation('Update'); + await withOperation('Delete'); + + test.done(); + }, }; diff --git a/packages/@aws-cdk/aws-lambda/test/test.log-retention.ts b/packages/@aws-cdk/aws-lambda/test/test.log-retention.ts index 8f53330174c1b..a70540adbbeb6 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.log-retention.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.log-retention.ts @@ -93,5 +93,23 @@ export = { test.done(); - } + }, + + 'use as a LogGroup'(test: Test) { + const stack = new cdk.Stack(); + const group = new LogRetention(stack, 'MyLambda', { + logGroupName: 'group', + retention: logs.RetentionDays.ONE_MONTH, + }); + + group.addStream('stream'); + + expect(stack).to(haveResource('AWS::Logs::LogStream', { + LogGroupName: { + 'Fn::GetAtt': [ 'MyLambdaCCE802FB', 'LogGroupName' ] + } + })); + + test.done(); + }, };