From 95fc82d2ebf96db8c1289ddb7a5af3d04261cf75 Mon Sep 17 00:00:00 2001 From: Tatsuya Yamamoto Date: Thu, 25 Nov 2021 07:05:33 +0900 Subject: [PATCH] feat(iot): add Action to capture CloudWatch metrics (#17503) I'm trying to implement aws-iot L2 Constructs. This PR is one of steps after following PR: - https://github.com/aws/aws-cdk/pull/16681#issuecomment-942233029 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-iot-actions/README.md | 25 ++++ .../lib/cloudwatch-put-metric-action.ts | 77 ++++++++++ .../@aws-cdk/aws-iot-actions/lib/index.ts | 1 + .../@aws-cdk/aws-iot-actions/package.json | 2 + .../cloudwatch-put-metric-action.test.ts | 134 ++++++++++++++++++ .../integ.cloudwatch-logs-action.ts | 1 - ...cloudwatch-put-metric-action.expected.json | 68 +++++++++ .../integ.cloudwatch-put-metric-action.ts | 26 ++++ .../integ.firehose-stream-action.ts | 1 - .../lambda/integ.lambda-function-action.ts | 1 - .../test/s3/integ.s3-put-object-action.ts | 1 - 11 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 packages/@aws-cdk/aws-iot-actions/lib/cloudwatch-put-metric-action.ts create mode 100644 packages/@aws-cdk/aws-iot-actions/test/cloudwatch/cloudwatch-put-metric-action.test.ts create mode 100644 packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.expected.json create mode 100644 packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.ts diff --git a/packages/@aws-cdk/aws-iot-actions/README.md b/packages/@aws-cdk/aws-iot-actions/README.md index e02f67cee0d45..bf2955757abe8 100644 --- a/packages/@aws-cdk/aws-iot-actions/README.md +++ b/packages/@aws-cdk/aws-iot-actions/README.md @@ -24,6 +24,7 @@ Currently supported are: - Invoke a Lambda function - Put objects to a S3 bucket - Put logs to CloudWatch Logs +- Capture CloudWatch metrics - Put records to Kinesis Data Firehose stream ## Invoke a Lambda function @@ -123,6 +124,30 @@ new iot.TopicRule(this, 'TopicRule', { }); ``` +## Capture CloudWatch metrics + +The code snippet below creates an AWS IoT Rule that capture CloudWatch metrics +when it is triggered. + +```ts +import * as iot from '@aws-cdk/aws-iot'; +import * as actions from '@aws-cdk/aws-iot-actions'; + +const topicRule = new iot.TopicRule(this, 'TopicRule', { + sql: iot.IotSql.fromStringAsVer20160323( + "SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'", + ), + actions: [ + new actions.CloudWatchPutMetricAction({ + metricName: '${topic(2)}', + metricNamespace: '${namespace}', + metricUnit: '${unit}', + metricValue: '${value}', + metricTimestamp: '${timestamp}', + }), + ], +}); +``` ## Put records to Kinesis Data Firehose stream diff --git a/packages/@aws-cdk/aws-iot-actions/lib/cloudwatch-put-metric-action.ts b/packages/@aws-cdk/aws-iot-actions/lib/cloudwatch-put-metric-action.ts new file mode 100644 index 0000000000000..90d7658e4a493 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/cloudwatch-put-metric-action.ts @@ -0,0 +1,77 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as iot from '@aws-cdk/aws-iot'; +import { CommonActionProps } from './common-action-props'; +import { singletonActionRole } from './private/role'; + +/** + * Configuration properties of an action for CloudWatch metric. + */ +export interface CloudWatchPutMetricActionProps extends CommonActionProps { + /** + * The CloudWatch metric name. + * + * Supports substitution templates. + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-substitution-templates.html + */ + readonly metricName: string; + + /** + * The CloudWatch metric namespace name. + * + * Supports substitution templates. + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-substitution-templates.html + */ + readonly metricNamespace: string; + + /** + * A string that contains the timestamp, expressed in seconds in Unix epoch time. + * + * Supports substitution templates. + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-substitution-templates.html + * + * @default - none -- Defaults to the current Unix epoch time. + */ + readonly metricTimestamp?: string; + + /** + * The metric unit supported by CloudWatch. + * + * Supports substitution templates. + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-substitution-templates.html + */ + readonly metricUnit: string; + + /** + * A string that contains the CloudWatch metric value. + * + * Supports substitution templates. + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-substitution-templates.html + */ + readonly metricValue: string; +} + +/** + * The action to capture an Amazon CloudWatch metric. + */ +export class CloudWatchPutMetricAction implements iot.IAction { + constructor(private readonly props: CloudWatchPutMetricActionProps) { + } + + bind(rule: iot.ITopicRule): iot.ActionConfig { + const role = this.props.role ?? singletonActionRole(rule); + cloudwatch.Metric.grantPutMetricData(role); + + return { + configuration: { + cloudwatchMetric: { + metricName: this.props.metricName, + metricNamespace: this.props.metricNamespace, + metricTimestamp: this.props.metricTimestamp, + metricUnit: this.props.metricUnit, + metricValue: this.props.metricValue, + roleArn: role.roleArn, + }, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/lib/index.ts b/packages/@aws-cdk/aws-iot-actions/lib/index.ts index ce74a2ff2b685..4ad9c1d2a1fb6 100644 --- a/packages/@aws-cdk/aws-iot-actions/lib/index.ts +++ b/packages/@aws-cdk/aws-iot-actions/lib/index.ts @@ -1,4 +1,5 @@ export * from './cloudwatch-logs-action'; +export * from './cloudwatch-put-metric-action'; export * from './common-action-props'; export * from './firehose-stream-action'; export * from './lambda-function-action'; diff --git a/packages/@aws-cdk/aws-iot-actions/package.json b/packages/@aws-cdk/aws-iot-actions/package.json index 8deede8b080c3..39b486cdcc285 100644 --- a/packages/@aws-cdk/aws-iot-actions/package.json +++ b/packages/@aws-cdk/aws-iot-actions/package.json @@ -80,6 +80,7 @@ "jest": "^27.3.1" }, "dependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", "@aws-cdk/aws-kinesisfirehose": "0.0.0", @@ -92,6 +93,7 @@ }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", "@aws-cdk/aws-kinesisfirehose": "0.0.0", diff --git a/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/cloudwatch-put-metric-action.test.ts b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/cloudwatch-put-metric-action.test.ts new file mode 100644 index 0000000000000..ed6b23bb90bde --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/cloudwatch-put-metric-action.test.ts @@ -0,0 +1,134 @@ +import { Template, Match } from '@aws-cdk/assertions'; +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +test('Default cloudwatch metric action', () => { + // GIVEN + const stack = new cdk.Stack(); + const topicRule = new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'"), + }); + + // WHEN + topicRule.addAction( + new actions.CloudWatchPutMetricAction({ + metricName: '${topic(2)}', + metricNamespace: '${namespace}', + metricUnit: '${unit}', + metricValue: '${value}', + }), + ); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + CloudwatchMetric: { + MetricName: '${topic(2)}', + MetricNamespace: '${namespace}', + MetricUnit: '${unit}', + MetricValue: '${value}', + RoleArn: { + 'Fn::GetAtt': ['MyTopicRuleTopicRuleActionRoleCE2D05DA', 'Arn'], + }, + }, + }, + ], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'iot.amazonaws.com', + }, + }, + ], + Version: '2012-10-17', + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'cloudwatch:PutMetricData', + Effect: 'Allow', + Resource: '*', + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyTopicRuleTopicRuleActionRoleDefaultPolicy54A701F7', + Roles: [{ Ref: 'MyTopicRuleTopicRuleActionRoleCE2D05DA' }], + }); +}); + +test('can set timestamp', () => { + // GIVEN + const stack = new cdk.Stack(); + const topicRule = new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'"), + }); + + // WHEN + topicRule.addAction( + new actions.CloudWatchPutMetricAction({ + metricName: '${topic(2)}', + metricNamespace: '${namespace}', + metricUnit: '${unit}', + metricValue: '${value}', + metricTimestamp: '${timestamp()}', + }), + ); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + Match.objectLike({ CloudwatchMetric: { MetricTimestamp: '${timestamp()}' } }), + ], + }, + }); +}); + +test('can set role', () => { + // GIVEN + const stack = new cdk.Stack(); + const topicRule = new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'"), + }); + const role = iam.Role.fromRoleArn(stack, 'MyRole', 'arn:aws:iam::123456789012:role/ForTest'); + + // WHEN + topicRule.addAction( + new actions.CloudWatchPutMetricAction({ + metricName: '${topic(2)}', + metricNamespace: '${namespace}', + metricUnit: '${unit}', + metricValue: '${value}', + role, + }), + ); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + Match.objectLike({ CloudwatchMetric: { RoleArn: 'arn:aws:iam::123456789012:role/ForTest' } }), + ], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyName: 'MyRolePolicy64AB00A5', + Roles: ['ForTest'], + }); +}); diff --git a/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-logs-action.ts b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-logs-action.ts index 802f485b77e37..ce8b3a8111c5d 100644 --- a/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-logs-action.ts +++ b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-logs-action.ts @@ -1,4 +1,3 @@ -/// !cdk-integ pragma:ignore-assets import * as iot from '@aws-cdk/aws-iot'; import * as logs from '@aws-cdk/aws-logs'; import * as cdk from '@aws-cdk/core'; diff --git a/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.expected.json new file mode 100644 index 0000000000000..c3ab0305174d4 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.expected.json @@ -0,0 +1,68 @@ +{ + "Resources": { + "TopicRule40A4EA44": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "CloudwatchMetric": { + "MetricName": "${topic(2)}", + "MetricNamespace": "${namespace}", + "MetricTimestamp": "${timestamp}", + "MetricUnit": "${unit}", + "MetricValue": "${value}", + "RoleArn": { + "Fn::GetAtt": [ + "TopicRuleTopicRuleActionRole246C4F77", + "Arn" + ] + } + } + } + ], + "AwsIotSqlVersion": "2016-03-23", + "Sql": "SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'" + } + } + }, + "TopicRuleTopicRuleActionRole246C4F77": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "cloudwatch:PutMetricData", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687", + "Roles": [ + { + "Ref": "TopicRuleTopicRuleActionRole246C4F77" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.ts b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.ts new file mode 100644 index 0000000000000..5a70d0bca3652 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/cloudwatch/integ.cloudwatch-put-metric-action.ts @@ -0,0 +1,26 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +const app = new cdk.App(); + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const topicRule = new iot.TopicRule(this, 'TopicRule', { + sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id, namespace, unit, value, timestamp FROM 'device/+/data'"), + }); + + topicRule.addAction(new actions.CloudWatchPutMetricAction({ + metricName: '${topic(2)}', + metricNamespace: '${namespace}', + metricUnit: '${unit}', + metricValue: '${value}', + metricTimestamp: '${timestamp}', + })); + } +} + +new TestStack(app, 'test-stack'); +app.synth(); diff --git a/packages/@aws-cdk/aws-iot-actions/test/kinesis-firehose/integ.firehose-stream-action.ts b/packages/@aws-cdk/aws-iot-actions/test/kinesis-firehose/integ.firehose-stream-action.ts index 9287f1294b4dd..2c6c93cf0460f 100644 --- a/packages/@aws-cdk/aws-iot-actions/test/kinesis-firehose/integ.firehose-stream-action.ts +++ b/packages/@aws-cdk/aws-iot-actions/test/kinesis-firehose/integ.firehose-stream-action.ts @@ -1,4 +1,3 @@ -/// !cdk-integ pragma:ignore-assets import * as iot from '@aws-cdk/aws-iot'; import * as firehose from '@aws-cdk/aws-kinesisfirehose'; import * as destinations from '@aws-cdk/aws-kinesisfirehose-destinations'; diff --git a/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-function-action.ts b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-function-action.ts index 58a7773afec03..b6052c80aeabf 100644 --- a/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-function-action.ts +++ b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-function-action.ts @@ -1,4 +1,3 @@ -/// !cdk-integ pragma:ignore-assets import * as iot from '@aws-cdk/aws-iot'; import * as lambda from '@aws-cdk/aws-lambda'; import * as cdk from '@aws-cdk/core'; diff --git a/packages/@aws-cdk/aws-iot-actions/test/s3/integ.s3-put-object-action.ts b/packages/@aws-cdk/aws-iot-actions/test/s3/integ.s3-put-object-action.ts index 9e100e0254eaf..eb9773fa395cd 100644 --- a/packages/@aws-cdk/aws-iot-actions/test/s3/integ.s3-put-object-action.ts +++ b/packages/@aws-cdk/aws-iot-actions/test/s3/integ.s3-put-object-action.ts @@ -1,4 +1,3 @@ -/// !cdk-integ pragma:ignore-assets import * as iot from '@aws-cdk/aws-iot'; import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core';