diff --git a/packages/@aws-cdk/aws-iot-actions/.eslintrc.js b/packages/@aws-cdk/aws-iot-actions/.eslintrc.js new file mode 100644 index 0000000000000..61dd8dd001f63 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/.eslintrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +module.exports = baseConfig; diff --git a/packages/@aws-cdk/aws-iot-actions/.gitignore b/packages/@aws-cdk/aws-iot-actions/.gitignore new file mode 100644 index 0000000000000..d8a8561d50885 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/.gitignore @@ -0,0 +1,19 @@ +*.js +*.js.map +*.d.ts +tsconfig.json +node_modules +*.generated.ts +dist +.jsii + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk +!.eslintrc.js +!jest.config.js + +junit.xml \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/.npmignore b/packages/@aws-cdk/aws-iot-actions/.npmignore new file mode 100644 index 0000000000000..63ab95621c764 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/.npmignore @@ -0,0 +1,27 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +!*.js + +# Include .jsii +!.jsii + +*.snk + +*.tsbuildinfo + +tsconfig.json +.eslintrc.js +jest.config.js + +# exclude cdk artifacts +**/cdk.out +junit.xml +test/ \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/LICENSE b/packages/@aws-cdk/aws-iot-actions/LICENSE new file mode 100644 index 0000000000000..28e4bdcec77ec --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/aws-iot-actions/NOTICE b/packages/@aws-cdk/aws-iot-actions/NOTICE new file mode 100644 index 0000000000000..5fc3826926b5b --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/aws-iot-actions/README.md b/packages/@aws-cdk/aws-iot-actions/README.md new file mode 100644 index 0000000000000..e0bd7aad9352f --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/README.md @@ -0,0 +1,30 @@ +# AWS IoT Construct Library + + +--- + +![cfn-resources: Stable](https://img.shields.io/badge/cfn--resources-stable-success.svg?style=for-the-badge) + +> All classes with the `Cfn` prefix in this module ([CFN Resources]) are always stable and safe to use. +> +> [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + +--- + + + +This module contains integration classes to add actions to IoT Topic Rules. +Instances of these classes should be passed to the `rule.addAction()` method. + +Currently supported are: + [Lambda](https://docs.aws.amazon.com/iot/latest/developerguide/lambda-rule-action.html) + +See the README of `@aws-cdk/aws-iot` for more information. diff --git a/packages/@aws-cdk/aws-iot-actions/jest.config.js b/packages/@aws-cdk/aws-iot-actions/jest.config.js new file mode 100644 index 0000000000000..54e28beb9798b --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/jest.config.js @@ -0,0 +1,2 @@ +const baseConfig = require('cdk-build-tools/config/jest.config'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/aws-iot-actions/lib/index.ts b/packages/@aws-cdk/aws-iot-actions/lib/index.ts new file mode 100644 index 0000000000000..0e44798488793 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/index.ts @@ -0,0 +1,4 @@ +export * from './lambda'; +export * from './republish'; +export * from './sns'; +export * from './sqs'; diff --git a/packages/@aws-cdk/aws-iot-actions/lib/lambda.ts b/packages/@aws-cdk/aws-iot-actions/lib/lambda.ts new file mode 100644 index 0000000000000..7f514c5f88f16 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/lambda.ts @@ -0,0 +1,39 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as cdk from '@aws-cdk/core'; + +/** + * Calls an AWS Lambda function + */ +export class LambdaFunction implements iot.ITopicRuleAction { + constructor(private readonly handler: lambda.IFunction) { + } + + public bind(rule: iot.ITopicRule): iot.TopicRuleActionConfig { + // Allow rule to invoke lambda function + const permissionId = 'AllowIot'; + if (!this.handler.permissionsNode.tryFindChild(permissionId)) { + this.handler.addPermission(permissionId, { + action: 'lambda:InvokeFunction', + principal: new iam.ServicePrincipal('iot.amazonaws.com'), + sourceAccount: cdk.Aws.ACCOUNT_ID, + }); + } + + // Ensure permission is deployed before rule + const permission = this.handler.permissionsNode.tryFindChild(permissionId) as lambda.CfnPermission; + if (permission) { + rule.node.addDependency(permission); + } else { + // tsling:disable-next-line:max-line-length + rule.node.addWarning('This rule is using a Lambda action with an imported function. Ensure permssion is given to IOT to invoke that function.'); + } + + return { + lambda: { + functionArn: this.handler.functionArn, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/lib/republish.ts b/packages/@aws-cdk/aws-iot-actions/lib/republish.ts new file mode 100644 index 0000000000000..78a526e92b7ef --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/republish.ts @@ -0,0 +1,47 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import { singletonTopicRuleRole } from './util'; + +/** + * Construction properties for a Republish action. + */ +export interface RepublishProps { + /** + * The name of the MQTT topic + */ + readonly topic: string; + /** + * The Quality of Service (Qos) level to use when republishing messages. + * + * @default - 0 + */ + readonly qos?: number; + /** + * The IAM role that grants access. + * + * @default - a role will be created + */ + readonly role?: iam.IRole; +} + +/** + * Publishes to a IoT Topic + */ +export class RepublishTopic implements iot.ITopicRuleAction { + constructor(private readonly topic: iot.ITopicRule, private readonly props: RepublishProps) { + } + + public bind(rule: iot.ITopicRule): iot.TopicRuleActionConfig { + // Allow rule to publish to topic + const role = this.props.role || singletonTopicRuleRole(rule, []); + this.topic.grantPublish(role, this.props.topic); + + return { + republish: { + qos: this.props.qos || 0, + topic: this.props.topic, + roleArn: role.roleArn, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/lib/sns.ts b/packages/@aws-cdk/aws-iot-actions/lib/sns.ts new file mode 100644 index 0000000000000..9616fbc8b148d --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/sns.ts @@ -0,0 +1,64 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import { singletonTopicRuleRole } from './util'; + +/** + * The allowd message formats + */ +export enum MessageFormats { + /** + * JSON topic message format + */ + JSON = 'JSON', + /** + * RAW topic message format + */ + RAW = 'RAW', +} +/** + * Construction properties for a sns publish action. + */ +export interface SnsTopicProps { + /** + * (Optional) The message format of the message to publish. Accepted values + * are "JSON" and "RAW". The default value of the attribute is "RAW". SNS uses + * this setting to determine if the payload should be parsed and relevant + * platform-specific bits of the payload should be extracted. + * + * https://docs.aws.amazon.com/sns/latest/dg/json-formats.html + * + * @default - MessageFormats.RAW + */ + readonly messageFormat?: MessageFormats; + /** + * The IAM role that grants access. + * + * @default - a role will be created + */ + readonly role?: iam.IRole; +} + +/** + * Publishes to a Topic + */ +export class SnsTopic implements iot.ITopicRuleAction { + constructor(private readonly topic: sns.ITopic, private readonly props: SnsTopicProps = {}) { + } + + public bind(_rule: iot.ITopicRule): iot.TopicRuleActionConfig { + // Allow rule to publish to topic + const grantable = this.props.role ? this.props.role : new iam.ServicePrincipal('iot.amazonaws.com'); + this.topic.grantPublish(grantable); + + const role = this.props.role ? this.props.role : singletonTopicRuleRole(_rule, []); + + return { + sns: { + messageFormat: this.props.messageFormat || MessageFormats.RAW, + targetArn: this.topic.topicArn, + roleArn: role.roleArn, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/lib/sqs.ts b/packages/@aws-cdk/aws-iot-actions/lib/sqs.ts new file mode 100644 index 0000000000000..147e0106f6e61 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/sqs.ts @@ -0,0 +1,46 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sqs from '@aws-cdk/aws-sqs'; +import { singletonTopicRuleRole } from './util'; + +/** + * Construction properties for a sqs send message action. + */ +export interface SqsQueueProps { + /** + * Specifies whether to use Base64 encoding. + * + * @default - false + */ + readonly useBase64?: boolean; + /** + * The IAM role that grants access. + * + * @default - a role will be created + */ + readonly role?: iam.IRole; +} + +/** + * Publishes to a Queue + */ +export class SqsQueue implements iot.ITopicRuleAction { + constructor(private readonly queue: sqs.IQueue, private readonly props: SqsQueueProps = {}) { + } + + public bind(_rule: iot.ITopicRule): iot.TopicRuleActionConfig { + // Allow rule to publish to topic + const grantable = this.props.role ? this.props.role : new iam.ServicePrincipal('iot.amazonaws.com'); + this.queue.grantSendMessages(grantable); + + const role = this.props.role ? this.props.role : singletonTopicRuleRole(_rule, []); + + return { + sqs: { + useBase64: this.props.useBase64 || false, + queueUrl: this.queue.queueUrl, + roleArn: role.roleArn, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/lib/util.ts b/packages/@aws-cdk/aws-iot-actions/lib/util.ts new file mode 100644 index 0000000000000..fa4be2040eca0 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/util.ts @@ -0,0 +1,27 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { IConstruct, Stack } from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +/** + * Obtain the Role for the Topic Rule + * + * If a role already exists, it will be returned. This ensures that if multiple + * events have the same target, they will share a role. + */ +export function singletonTopicRuleRole(scope: IConstruct, policyStatements: iam.PolicyStatement[]): iam.IRole { + const stack = Stack.of(scope); + const id = 'AllowIot'; + const existing = stack.node.tryFindChild(id) as iam.IRole; + if (existing) { return existing; } + + const role = new iam.Role(scope as Construct, id, { + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + + policyStatements.forEach(role.addToPolicy.bind(role)); + + return role; +} diff --git a/packages/@aws-cdk/aws-iot-actions/package.json b/packages/@aws-cdk/aws-iot-actions/package.json new file mode 100644 index 0000000000000..dd360ab16a22e --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/package.json @@ -0,0 +1,115 @@ +{ + "name": "@aws-cdk/aws-iot-actions", + "version": "0.0.0", + "description": "Topic rule actions for AWS IoT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "targets": { + "java": { + "package": "software.amazon.awscdk.services.iot.actions", + "maven": { + "groupId": "software.amazon.awscdk", + "artifactId": "iot-actions", + "versionSuffix": ".DEVPREVIEW" + } + }, + "dotnet": { + "namespace": "Amazon.CDK.AWS.IoT.Actions", + "packageId": "Amazon.CDK.AWS.IoT.Actions", + "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png" + }, + "python": { + "distName": "aws-cdk.aws-iot-actions", + "module": "aws_cdk.aws_iot_actions", + "classifiers": [ + "Framework :: AWS CDK", + "Framework :: AWS CDK :: 1" + ] + } + }, + "projectReferences": true + }, + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-cdk.git", + "directory": "packages/@aws-cdk/aws-iot-actions" + }, + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "integ": "cdk-integ", + "pkglint": "pkglint -f", + "package": "cdk-package", + "awslint": "cdk-awslint", + "cfn2ts": "cfn2ts", + "build+test+package": "yarn build+test && yarn package", + "build+test": "yarn build && yarn test", + "compat": "cdk-compat", + "gen": "cfn2ts", + "rosetta:extract": "yarn --silent jsii-rosetta extract" + }, + "cdk-build": { + "cloudformation": "AWS::IoT", + "jest": true, + "env": { + "AWSLINT_BASE_CONSTRUCT": true + } + }, + "keywords": [ + "aws", + "cdk", + "constructs", + "iot", + "actions" + ], + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "@aws-cdk/assert": "0.0.0", + "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", + "cfn2ts": "0.0.0", + "constructs": "^3.2.0", + "pkglint": "0.0.0", + "nodeunit-shim": "0.0.0" + }, + "dependencies": { + "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/aws-sqs": "0.0.0" + }, + "homepage": "https://github.com/aws/aws-cdk", + "peerDependencies": { + "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/aws-sqs": "0.0.0" + }, + "engines": { + "node": ">= 10.13.0 <13 || >=13.7.0" + }, + "awslint": { + "exclude": [] + }, + "maturity": "experimental", + "stability": "experimental", + "awscdkio": { + "announce": false + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.expected.json b/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.expected.json new file mode 100644 index 0000000000000..e8644877e9482 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.expected.json @@ -0,0 +1,239 @@ +{ + "Resources": { + "ErrorTopicA0904A23": { + "Type": "AWS::SNS::Topic" + }, + "ErrorTopicPolicyA43559E4": { + "Type": "AWS::SNS::TopicPolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + }, + "Resource": { + "Ref": "ErrorTopicA0904A23" + }, + "Sid": "0" + } + ], + "Version": "2012-10-17" + }, + "Topics": [ + { + "Ref": "ErrorTopicA0904A23" + } + ] + } + }, + "MyNotifierTopicAllowIot154F3A47": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + }, + "DependsOn": [ + "FunctionAllowIotD2ECA9CD" + ] + }, + "MyNotifierTopicC60F4F3C": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Lambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + } + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "ErrorAction": { + "Sns": { + "MessageFormat": "RAW", + "RoleArn": { + "Fn::GetAtt": [ + "MyNotifierTopicAllowIot154F3A47", + "Arn" + ] + }, + "TargetArn": { + "Ref": "ErrorTopicA0904A23" + } + } + }, + "RuleDisabled": false, + "Sql": "SELECT teapots FROM 'coffee/shop'" + } + }, + "DependsOn": [ + "FunctionAllowIotD2ECA9CD" + ] + }, + "FunctionServiceRole675BB04A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Function76856677": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "boom" + }, + "Role": { + "Fn::GetAtt": [ + "FunctionServiceRole675BB04A", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "FunctionServiceRole675BB04A" + ] + }, + "FunctionAllowIotD2ECA9CD": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "Principal": "iot.amazonaws.com", + "SourceAccount": { + "Ref": "AWS::AccountId" + } + } + }, + "MyWorkTopicE967237A": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Republish": { + "Qos": 0, + "RoleArn": { + "Fn::GetAtt": [ + "MyWorkTopicAllowIot06B368E4", + "Arn" + ] + }, + "Topic": "coffee/shop" + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT * FROM 'inventory'" + } + } + }, + "MyWorkTopicAllowIot06B368E4": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyWorkTopicAllowIotDefaultPolicy13C30DF0": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iot:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":topic/coffee/shop" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyWorkTopicAllowIotDefaultPolicy13C30DF0", + "Roles": [ + { + "Ref": "MyWorkTopicAllowIot06B368E4" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.ts b/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.ts new file mode 100644 index 0000000000000..784be670fa529 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/integ.kit-and-caboodle.ts @@ -0,0 +1,27 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as sns from '@aws-cdk/aws-sns'; +import { App, Stack } from '@aws-cdk/core'; +import * as actions from '../lib'; + +const app = new App(); +const stack = new Stack(app, 'MyTopicRule'); + +const rule = new iot.TopicRule(stack, 'MyNotifierTopic', { + sql: 'SELECT teapots FROM \'coffee/shop\'', + errorAction: new actions.SnsTopic(new sns.Topic(stack, 'ErrorTopic')), +}); + +const func = new lambda.Function(stack, 'Function', { + code: lambda.Code.fromInline('boom'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, +}); + +rule.addAction(new actions.LambdaFunction(func)); + +new iot.TopicRule(stack, 'MyWorkTopic', { + sql: 'SELECT * FROM \'inventory\'', + actions: [new actions.RepublishTopic(rule, { topic: 'coffee/shop' })], +}); + diff --git a/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.expected.json new file mode 100644 index 0000000000000..27b50af4c8f86 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.expected.json @@ -0,0 +1,95 @@ +{ + "Resources": { + "FunctionServiceRole675BB04A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Function76856677": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "boom" + }, + "Role": { + "Fn::GetAtt": [ + "FunctionServiceRole675BB04A", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "FunctionServiceRole675BB04A" + ] + }, + "FunctionAllowIotD2ECA9CD": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "Principal": "iot.amazonaws.com", + "SourceAccount": { + "Ref": "AWS::AccountId" + } + } + }, + "MyIotTopicRuleFE4E2C8B": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Lambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + } + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT * FROM 'topic/subtopic'" + } + }, + "DependsOn": [ + "FunctionAllowIotD2ECA9CD" + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.ts b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.ts new file mode 100644 index 0000000000000..8bb3ca39ebbdf --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/lambda/integ.lambda-topic-rule-action.ts @@ -0,0 +1,28 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as lambda from '@aws-cdk/aws-lambda'; + +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +/** + * Define a rule that triggers a Lambda function when data is received. + * + * Automatically creates invoke permission + */ +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-lambda-topic-rule-action'); + +const func = new lambda.Function(stack, 'Function', { + code: lambda.Code.fromInline('boom'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, +}); + +const rule = new iot.TopicRule(stack, 'MyIotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', +}); + +rule.addAction(new actions.LambdaFunction(func)); + +app.synth(); diff --git a/packages/@aws-cdk/aws-iot-actions/test/lambda/lambda.test.ts b/packages/@aws-cdk/aws-iot-actions/test/lambda/lambda.test.ts new file mode 100644 index 0000000000000..a326db57fb710 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/lambda/lambda.test.ts @@ -0,0 +1,105 @@ +import '@aws-cdk/assert/jest'; +import * as iot from '@aws-cdk/aws-iot'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import * as actions from '../../lib'; + +test('add lambda action', () => { + const stack = new Stack(); + const rule = new iot.TopicRule(stack, 'TopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + }); + const fn = new lambda.Function(stack, 'Function', { + code: lambda.Code.fromInline('boom'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_8_10, + }); + + rule.addAction(new actions.LambdaFunction(fn)); + + expect(stack).toHaveResourceLike('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Lambda: { FunctionArn: { 'Fn::GetAtt': ['Function76856677', 'Arn'] } }, + }, + ], + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + }); + + expect(stack).toHaveResourceLike('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + FunctionName: { + 'Fn::GetAtt': [ + 'Function76856677', + 'Arn', + ], + }, + Principal: 'iot.amazonaws.com', + SourceAccount: { + Ref: 'AWS::AccountId', + }, + }); +}); +test('adding different lambda functions as target mutiple times creates multiple permissions', () => { + // GIVEN + const stack = new Stack(); + const fn1 = newTestLambda(stack); + const fn2 = newTestLambda(stack, '2'); + const rule = new iot.TopicRule(stack, 'Rule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.LambdaFunction(fn1)); + rule.addAction(new actions.LambdaFunction(fn2)); + + // THEN + expect(stack).toCountResources('AWS::Lambda::Permission', 2); +}); +test('adding same lambda function as target mutiple times creates permission only once', () => { + // GIVEN + const stack = new Stack(); + const fn = newTestLambda(stack); + const rule = new iot.TopicRule(stack, 'Rule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.LambdaFunction(fn)); + rule.addAction(new actions.LambdaFunction(fn)); + + // THEN + expect(stack).toCountResources('AWS::Lambda::Permission', 1); +}); +test('adding same singleton lambda function as target mutiple times creates permission only once', () => { + // GIVEN + const stack = new Stack(); + const fn = new lambda.SingletonFunction(stack, 'MyLambda', { + code: new lambda.InlineCode('foo'), + handler: 'bar', + runtime: lambda.Runtime.PYTHON_2_7, + uuid: 'uuid', + }); + + const rule = new iot.TopicRule(stack, 'Rule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.LambdaFunction(fn)); + rule.addAction(new actions.LambdaFunction(fn)); + + // THEN + expect(stack).toCountResources('AWS::Lambda::Permission', 1); +}); +function newTestLambda(scope: Construct, suffix = '') { + return new lambda.Function(scope, `MyLambda${suffix}`, { + code: new lambda.InlineCode('foo'), + handler: 'bar', + runtime: lambda.Runtime.PYTHON_2_7, + }); +} diff --git a/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.expected.json new file mode 100644 index 0000000000000..5c1414c3df72f --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.expected.json @@ -0,0 +1,157 @@ +{ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic" + }, + "MyTopicPolicy12A5EC17": { + "Type": "AWS::SNS::TopicPolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + }, + "Resource": { + "Ref": "MyTopic86869434" + }, + "Sid": "0" + } + ], + "Version": "2012-10-17" + }, + "Topics": [ + { + "Ref": "MyTopic86869434" + } + ] + } + }, + "DownstreamTopicBE449D24": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Sns": { + "MessageFormat": "RAW", + "RoleArn": { + "Fn::GetAtt": [ + "DownstreamTopicAllowIotB0ED12F8", + "Arn" + ] + }, + "TargetArn": { + "Ref": "MyTopic86869434" + } + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT teapots FROM 'inventory'" + } + } + }, + "DownstreamTopicAllowIotB0ED12F8": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyRepublishTopicRuleD870AE64": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Republish": { + "Qos": 0, + "RoleArn": { + "Fn::GetAtt": [ + "MyRepublishTopicRuleAllowIotE447F1AF", + "Arn" + ] + }, + "Topic": "inventory" + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT teapots FROM 'coffee/shop'" + } + } + }, + "MyRepublishTopicRuleAllowIotE447F1AF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyRepublishTopicRuleAllowIotDefaultPolicy0E6BCB09": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iot:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":topic/inventory" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyRepublishTopicRuleAllowIotDefaultPolicy0E6BCB09", + "Roles": [ + { + "Ref": "MyRepublishTopicRuleAllowIotE447F1AF" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.ts b/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.ts new file mode 100644 index 0000000000000..5a02564a2ab54 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/republish/integ.republish-topic-rule-action.ts @@ -0,0 +1,24 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +/** +* Define a rule that triggers to republish received data. +* Automatically creates invoke lambda permission +*/ +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-republish-topic-rule-action'); + +const downstream = new iot.TopicRule(stack, 'DownstreamTopic', { + sql: 'SELECT teapots FROM \'inventory\'', + actions: [new actions.SnsTopic(new sns.Topic(stack, 'MyTopic'))], +}); + +new iot.TopicRule(stack, 'MyRepublishTopicRule', { + sql: 'SELECT teapots FROM \'coffee/shop\'', + actions: [new actions.RepublishTopic(downstream, { topic: 'inventory' })], +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-iot-actions/test/republish/republish.test.ts b/packages/@aws-cdk/aws-iot-actions/test/republish/republish.test.ts new file mode 100644 index 0000000000000..ac318344549d8 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/republish/republish.test.ts @@ -0,0 +1,67 @@ +import '@aws-cdk/assert/jest'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import { Stack } from '@aws-cdk/core'; +import * as actions from '../../lib'; + +let stack: Stack; +let rule: iot.TopicRule; + +test('add republish action', () => { + stack = new Stack(); + rule = new iot.TopicRule(stack, 'RepublishRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + }); + + const downstream = new iot.TopicRule(stack, 'DownstreamRule', { + sql: 'SELECT teapot FROM \'coffe/shop\'', + actions: [new actions.SnsTopic(new sns.Topic(stack, 'MySnsTopic'))], + }); + + rule.addAction(new actions.RepublishTopic(downstream, { topic: 'coffee/shop' })); + + expect(stack).toHaveResourceLike('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Republish: { + Qos: 0, + RoleArn: { 'Fn::GetAtt': ['RepublishRuleAllowIotB39A8B3C', 'Arn'] }, + Topic: 'coffee/shop', + }, + }, + ], + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iot:Publish', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iot:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':topic/coffee/shop', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'RepublishRuleAllowIotDefaultPolicy3B0C81B5', + Roles: [ + { Ref: 'RepublishRuleAllowIotB39A8B3C' }, + ], + }); +}); diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.expected.json new file mode 100644 index 0000000000000..97ab8db500d3b --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.expected.json @@ -0,0 +1,76 @@ +{ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic" + }, + "MyTopicPolicy12A5EC17": { + "Type": "AWS::SNS::TopicPolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + }, + "Resource": { + "Ref": "MyTopic86869434" + }, + "Sid": "0" + } + ], + "Version": "2012-10-17" + }, + "Topics": [ + { + "Ref": "MyTopic86869434" + } + ] + } + }, + "My84E10354": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Sns": { + "MessageFormat": "RAW", + "RoleArn": { + "Fn::GetAtt": [ + "MyAllowIotE8084C57", + "Arn" + ] + }, + "TargetArn": { + "Ref": "MyTopic86869434" + } + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT * FROM 'topic/subtopic'" + } + } + }, + "MyAllowIotE8084C57": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.ts b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.ts new file mode 100644 index 0000000000000..06718ddf7b6ce --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-rule-action.ts @@ -0,0 +1,19 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; + +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-topic-rule-sns-action'); + +const topic = new sns.Topic(stack, 'MyTopic'); + +const rule = new iot.TopicRule(stack, 'My', { + sql: 'SELECT * FROM \'topic/subtopic\'', +}); + +rule.addAction(new actions.SnsTopic(topic)); + +app.synth(); diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/sns.test.ts b/packages/@aws-cdk/aws-iot-actions/test/sns/sns.test.ts new file mode 100644 index 0000000000000..d0ba5641e80d8 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/sns.test.ts @@ -0,0 +1,118 @@ +import { expect, haveResource, countResources } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import { Stack } from '@aws-cdk/core'; +import * as actions from '../../lib'; + +test('sns topic as a rule action', () => { + // GIVEN + const stack = new Stack(); + const topic = new sns.Topic(stack, 'MyTopic'); + const rule = new iot.TopicRule(stack, 'MyRule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.SnsTopic(topic)); + + // THEN + expect(stack).to(haveResource('AWS::SNS::TopicPolicy', { + PolicyDocument: { + Statement: [ + { + Sid: '0', + Action: 'sns:Publish', + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + Resource: { Ref: 'MyTopic86869434' }, + }, + ], + Version: '2012-10-17', + }, + Topics: [{ Ref: 'MyTopic86869434' }], + })); + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Sns: { + MessageFormat: 'RAW', + RoleArn: { + 'Fn::GetAtt': ['MyRuleAllowIot33481905', 'Arn'], + }, + TargetArn: { Ref: 'MyTopic86869434' }, + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT', + }, + })); +}); + +test('sns topic with role as a rule action', () => { + // GIVEN + const stack = new Stack(); + const topic = new sns.Topic(stack, 'MyTopic'); + const role = new iam.Role(stack, 'MyCustomRole', { + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + const rule = new iot.TopicRule(stack, 'MyRule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.SnsTopic(topic, { role: role })); + + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Sns: { + MessageFormat: 'RAW', + RoleArn: { + 'Fn::GetAtt': ['MyCustomRoleC8C89DCB', 'Arn'], + }, + TargetArn: { Ref: 'MyTopic86869434' }, + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT', + }, + })); +}); +test('multiple uses of a topic as a target results in a single policy statement', () => { + // GIVEN + const stack = new Stack(); + const topic = new sns.Topic(stack, 'MyTopic'); + + //WHEN + for (let i = 0; i < 5; ++i) { + const rule = new iot.TopicRule(stack, `Rule${i}`, { + sql: 'SELECT', + }); + rule.addAction(new actions.SnsTopic(topic)); + } + + // THEN + expect(stack).to(countResources('AWS::SNS::TopicPolicy', 1)); + expect(stack).to(haveResource('AWS::SNS::TopicPolicy', { + PolicyDocument: { + Statement: [ + { + Sid: '0', + Action: 'sns:Publish', + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + Resource: { Ref: 'MyTopic86869434' }, + }, + ], + Version: '2012-10-17', + }, + Topics: [{ Ref: 'MyTopic86869434' }], + })); +}); diff --git a/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.expected.json new file mode 100644 index 0000000000000..6cb06af4a94a2 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.expected.json @@ -0,0 +1,84 @@ +{ + "Resources": { + "MyQueueE6CA6235": { + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "MyQueuePolicy6BBEDDAC": { + "Type": "AWS::SQS::QueuePolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + }, + "Resource": { + "Fn::GetAtt": [ + "MyQueueE6CA6235", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "Queues": [ + { + "Ref": "MyQueueE6CA6235" + } + ] + } + }, + "My84E10354": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Sqs": { + "QueueUrl": { + "Ref": "MyQueueE6CA6235" + }, + "RoleArn": { + "Fn::GetAtt": [ + "MyAllowIotE8084C57", + "Arn" + ] + }, + "UseBase64": false + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT * FROM 'topic/subtopic'" + } + } + }, + "MyAllowIotE8084C57": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.ts b/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.ts new file mode 100644 index 0000000000000..bf3c085ea53a4 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sqs/integ.sqs-topic-rule-action.ts @@ -0,0 +1,19 @@ +import * as iot from '@aws-cdk/aws-iot'; +import * as sqs from '@aws-cdk/aws-sqs'; + +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-topic-rule-sns-action'); + +const queue = new sqs.Queue(stack, 'MyQueue'); + +const rule = new iot.TopicRule(stack, 'My', { + sql: 'SELECT * FROM \'topic/subtopic\'', +}); + +rule.addAction(new actions.SqsQueue(queue)); + +app.synth(); diff --git a/packages/@aws-cdk/aws-iot-actions/test/sqs/sqs.test.ts b/packages/@aws-cdk/aws-iot-actions/test/sqs/sqs.test.ts new file mode 100644 index 0000000000000..1bac6e3e698c9 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sqs/sqs.test.ts @@ -0,0 +1,128 @@ +import { expect, haveResource, countResources } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sqs from '@aws-cdk/aws-sqs'; +import { Stack } from '@aws-cdk/core'; +import * as actions from '../../lib'; + +test('sns topic as a rule action', () => { + // GIVEN + const stack = new Stack(); + const queue = new sqs.Queue(stack, 'MyQueue'); + const rule = new iot.TopicRule(stack, 'MyRule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.SqsQueue(queue)); + + // THEN + expect(stack).to(haveResource('AWS::SQS::QueuePolicy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'sqs:SendMessage', + 'sqs:GetQueueAttributes', + 'sqs:GetQueueUrl', + ], + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + Resource: { + 'Fn::GetAtt': ['MyQueueE6CA6235', 'Arn'], + }, + }, + ], + Version: '2012-10-17', + }, + Queues: [{ Ref: 'MyQueueE6CA6235' }], + })); + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Sqs: { + QueueUrl: { Ref: 'MyQueueE6CA6235' }, + RoleArn: { + 'Fn::GetAtt': ['MyRuleAllowIot33481905', 'Arn'], + }, + UseBase64: false, + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT', + }, + })); +}); + +test('sqs topic with role as a rule action', () => { + // GIVEN + const stack = new Stack(); + const queue = new sqs.Queue(stack, 'MyQueue'); + const role = new iam.Role(stack, 'MyCustomRole', { + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + const rule = new iot.TopicRule(stack, 'MyRule', { + sql: 'SELECT', + }); + + // WHEN + rule.addAction(new actions.SqsQueue(queue, { role: role })); + + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Sqs: { + QueueUrl: { Ref: 'MyQueueE6CA6235' }, + RoleArn: { + 'Fn::GetAtt': ['MyCustomRoleC8C89DCB', 'Arn'], + }, + UseBase64: false, + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT', + }, + })); +}); +test('multiple uses of a queue as an action results in a single policy statement', () => { + // GIVEN + const stack = new Stack(); + const queue = new sqs.Queue(stack, 'MyQueue'); + + //WHEN + for (let i = 0; i < 5; ++i) { + const rule = new iot.TopicRule(stack, `Rule${i}`, { + sql: 'SELECT', + }); + rule.addAction(new actions.SqsQueue(queue)); + } + + // THEN + expect(stack).to(countResources('AWS::SQS::QueuePolicy', 1)); + expect(stack).to(haveResource('AWS::SQS::QueuePolicy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'sqs:SendMessage', + 'sqs:GetQueueAttributes', + 'sqs:GetQueueUrl', + ], + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + Resource: { + 'Fn::GetAtt': ['MyQueueE6CA6235', 'Arn'], + }, + }, + ], + Version: '2012-10-17', + }, + Queues: [{ Ref: 'MyQueueE6CA6235' }], + })); +}); diff --git a/packages/@aws-cdk/aws-iot/README.md b/packages/@aws-cdk/aws-iot/README.md index 7334c9d4e108e..e21793b631929 100644 --- a/packages/@aws-cdk/aws-iot/README.md +++ b/packages/@aws-cdk/aws-iot/README.md @@ -9,6 +9,14 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- diff --git a/packages/@aws-cdk/aws-iot/lib/index.ts b/packages/@aws-cdk/aws-iot/lib/index.ts index 4f78a6cf531e3..11c0c38a7db4a 100644 --- a/packages/@aws-cdk/aws-iot/lib/index.ts +++ b/packages/@aws-cdk/aws-iot/lib/index.ts @@ -1,2 +1,4 @@ +export * from './topic-rule'; +export * from './topic-rule-action'; // AWS::IoT CloudFormation Resources: export * from './iot.generated'; diff --git a/packages/@aws-cdk/aws-iot/lib/topic-rule-action.ts b/packages/@aws-cdk/aws-iot/lib/topic-rule-action.ts new file mode 100644 index 0000000000000..278b2536131f9 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/topic-rule-action.ts @@ -0,0 +1,104 @@ +import { CfnTopicRule } from './iot.generated'; +import { ITopicRule } from './topic-rule'; + +/** + * An abstract action for a topic rule. + */ +export interface ITopicRuleAction { + /** + * Returns the topic rule action specification + */ + bind(topicRule: ITopicRule): TopicRuleActionConfig; +} + +/** + * Properties for a topic rule action. + */ +export interface TopicRuleActionConfig { + /** + * Describes an action that updates a CloudWatch alarm. + */ + readonly cloudwatchAlarm?: CfnTopicRule.CloudwatchAlarmActionProperty; + /** + * Describes an action that captures a CloudWatch metric. + */ + readonly cloudwatchAlarmMetric?: CfnTopicRule.CloudwatchMetricActionProperty; + /** + * Describes an action to write to a DynamoDB table. + * + * The tableName, hashKeyField, and rangeKeyField values must match the values + * used when you created the table. + * + * The hashKeyValue and rangeKeyvalue fields use a substitution template + * syntax. + * These templates provide data at runtime. The syntax is as follows: + * ${sql-expression}. + * + * You can specify any valid expression in a WHERE or SELECT clause, including JSON + * properties, comparisons, calculations, and functions. For example, the following + * field uses the third level of the topic: + * + * "hashKeyValue": "${topic(3)}" + * + * The following field uses the timestamp: + * + * "rangeKeyValue": "${timestamp()}" + */ + readonly dynamoDB?: CfnTopicRule.DynamoDBActionProperty; + /** + * Describes an action to write to a DynamoDB table. + * + * This DynamoDB action writes each attribute in the message payload into it's + * own column in the DynamoDB table. + */ + readonly dynamoDBv2?: CfnTopicRule.DynamoDBv2ActionProperty; + /** + * Describes an action that writes data to an Amazon Elasticsearch Service domain. + */ + readonly elasticsearch?: CfnTopicRule.ElasticsearchActionProperty; + /** + * Describes an action that writes data to an Amazon Kinesis Firehose stream. + */ + readonly firehose?: CfnTopicRule.FirehoseActionProperty; + /** + * Send data to an HTTPS endpoint. + */ + readonly http?: CfnTopicRule.HttpActionProperty; + /** + * Sends message data to an AWS IoT Analytics channel. + */ + readonly iotAnalytics?: CfnTopicRule.IotAnalyticsActionProperty; + /** + * Describes an action to send data from an MQTT message that triggered the + * rule to AWS IoT SiteWise asset properties. + */ + readonly iotSiteWise?: CfnTopicRule.IotSiteWiseActionProperty; + /** + * Describes an action to write data to an Amazon Kinesis stream. + */ + readonly kinesis?: CfnTopicRule.KinesisActionProperty; + /** + * Describes an action to invoke a Lambda function. + */ + readonly lambda?: CfnTopicRule.LambdaActionProperty; + /** + * Describes an action to republish to another topic. + */ + readonly republish?: CfnTopicRule.RepublishActionProperty; + /** + * Describes an action to write data to an Amazon S3 bucket. + */ + readonly s3?: CfnTopicRule.S3ActionProperty; + /** + * Describes an action to publish to an Amazon SNS topic. + */ + readonly sns?: CfnTopicRule.SnsActionProperty; + /** + * Describes an action to publish data to an Amazon SQS queue. + */ + readonly sqs?: CfnTopicRule.SqsActionProperty; + /** + * Starts execution of a Step Functions state machine. + */ + readonly stepFunctions?: CfnTopicRule.StepFunctionsActionProperty; +} diff --git a/packages/@aws-cdk/aws-iot/lib/topic-rule.ts b/packages/@aws-cdk/aws-iot/lib/topic-rule.ts new file mode 100644 index 0000000000000..45bfab74a6f0c --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/topic-rule.ts @@ -0,0 +1,221 @@ +import { Grant, IGrantable, PolicyStatement, AddToResourcePolicyResult } from '@aws-cdk/aws-iam'; +import { Resource, IResource, Lazy } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnTopicRule } from './iot.generated'; +import { ITopicRuleAction } from './topic-rule-action'; +import { singletonTopicRuleRole, parseRuleName, undefinedIfAllValuesAreEmpty, topicArn } from './util'; + +/** + * The AWS IoT rules engine uses an SQL-like syntax to select data from MQTT + * messages. The SQL statements are interpreted based on an SQL version + * specified with the awsIotSqlVersion property in a JSON document that describes + * the rule. + */ +export enum AwsIotSqlVersion { + /** + * Version beta + * + * The most recent beta SQL version. If you use this version, it might + * introduce breaking changes to your rules. + */ + BETA = 'beta', + /** + * Version 2015-10-08 + * + * The original SQL version built on 2015-10-08. + */ + VERSION2015_10_08 = '2015-10-08', + /** + * Version 2016-03-23 + * + * The SQL version built on 2016-03-23. + * + * Supports secrets, task recycling. + */ + VERSION2016_03_23 = '2016-03-23', +} +/** + * An IoT Topic Rule + */ +export interface ITopicRule extends IResource { + /** + * Then name of the topic rule + */ + readonly ruleName: string; + /** + * Then name of the topic rule + * + * @attribute + * + */ + readonly topicRuleArn: string; + /** + * Adds a statement to the IAM resource created for this topic rule + */ + addToResourcePolicy(statement: PolicyStatement): AddToResourcePolicyResult; + /** + * Grant topic publishing permissions to the given identity + */ + grantPublish(identity: IGrantable, topic: string): Grant; +} +/** + * A reference to an IoT Topic Rule. + */ +export interface TopicRuleAttributes { + /** + * The ARN of the IoT Topic Rule. At least one of bucketArn or bucketName must be + * defined in order to initialize a bucket ref. + */ + readonly topicRuleArn: string; +} +/** + * Properties for defining an IoT Topic Rule. + */ +export interface TopicRuleProps { + /** + * The name of the rule. + * + * @default - generated + */ + readonly ruleName?: string + /** + * The rule actions. + * + * @default - empty + */ + readonly actions?: ITopicRuleAction[]; + /** + * The rule enabled status. + * + * @default - false + */ + readonly ruleDisabled?: boolean; + /** + * The rule sql. + */ + readonly sql: string; + /** + * The rule AWS IoT SQL version. + * + * @default - 2012-17-10 + */ + readonly awsIotSqlVersion?: AwsIotSqlVersion; + /** + * The topic rule description. + * + * @default - none + */ + readonly description?: string; + /** + * The rule actions to preform on error. + * + * @default - none + */ + readonly errorAction?: ITopicRuleAction; +} + +/** + * Either a new or imported topic rule + */ +export abstract class TopicRuleBase extends Resource implements ITopicRule { + public abstract readonly ruleName: string; + public abstract readonly topicRuleArn: string; + + /** + * Add a statement to the IAM resource for this rule + */ + public addToResourcePolicy(statement: PolicyStatement): AddToResourcePolicyResult { + singletonTopicRuleRole(this, [statement]); + return { statementAdded: true }; + } + + /** + * Grant topic publishing permissions to the given identity + */ + public grantPublish(grantee: IGrantable, topic: string): Grant { + return Grant.addToPrincipalOrResource({ + grantee, + actions: ['iot:Publish'], + resourceArns: [topicArn(this, topic)], + resource: this, + }); + } +} +/** + * A new topic rule + */ +export class TopicRule extends TopicRuleBase { + /** + * Import topic rule attributes + */ + public static fromTopicRuleArn(scope: Construct, id: string, topicRuleArn: string): ITopicRule { + return TopicRule.fromTopicRuleAttributes(scope, id, { topicRuleArn }); + } + /** + * Import topic rule attributes + */ + public static fromTopicRuleAttributes(scope: Construct, id: string, attrs: TopicRuleAttributes): ITopicRule { + const topicRuleArn = attrs.topicRuleArn; + const ruleName = parseRuleName(attrs.topicRuleArn); + class Import extends TopicRuleBase { + public readonly ruleName = ruleName; + public readonly topicRuleArn = topicRuleArn; + } + + return new Import(scope, id); + } + + public readonly ruleName: string; + public readonly topicRuleArn: string; + private readonly actions = new Array(); + private errorAction: CfnTopicRule.ActionProperty = {}; + + constructor(scope: Construct, id: string, props: TopicRuleProps) { + super(scope, id, { + physicalName: props.ruleName, + }); + + if (props.errorAction) { + this.addErrorAction(props.errorAction); + } + + const resource = new CfnTopicRule(this, 'Resource', { + ruleName: this.physicalName, + topicRulePayload: { + errorAction: Lazy.any({ produce: () => undefinedIfAllValuesAreEmpty(this.errorAction) }), + description: props.description, + awsIotSqlVersion: props.awsIotSqlVersion || AwsIotSqlVersion.VERSION2015_10_08, + sql: props.sql, + ruleDisabled: props.ruleDisabled || false, + actions: Lazy.any({ produce: () => this.renderActions() }), + }, + }); + + this.ruleName = resource.ref; + this.topicRuleArn = resource.attrArn; + for (const action of props.actions || []) { + this.addAction(action); + } + } + + /** + * Adds an action to this topic rule. + */ + public addAction(action: ITopicRuleAction) { + this.actions.push(action.bind(this)); + } + + /** + * Adds an error action to this topic rule. + */ + public addErrorAction(action: ITopicRuleAction) { + this.errorAction = action.bind(this); + } + + public renderActions() { + if (this.actions.length === 0) { + throw new Error('Topic invalid. Must contain at least one action'); + } + return this.actions; + } +} diff --git a/packages/@aws-cdk/aws-iot/lib/util.ts b/packages/@aws-cdk/aws-iot/lib/util.ts new file mode 100644 index 0000000000000..8f6f4dece8eb7 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/util.ts @@ -0,0 +1,42 @@ +import { IRole, PolicyStatement, ServicePrincipal, Role } from '@aws-cdk/aws-iam'; +import { Fn, IConstruct, Stack, Arn } from '@aws-cdk/core'; +export { undefinedIfAllValuesAreEmpty } from '@aws-cdk/core/lib/util'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +export function parseRuleName(topicRuleArn: string): string { + return Fn.select(1, Fn.split('rule/', topicRuleArn)); +} +/** + * Obtain the Role for the Topic Rule + * + * If a role already exists, it will be returned. This ensures that if multiple + * events have the same target, they will share a role. + */ +export function singletonTopicRuleRole(scope: IConstruct, policyStatements: PolicyStatement[]): IRole { + const stack = Stack.of(scope); + const id = 'AllowIot'; + const existing = stack.node.tryFindChild(id) as IRole; + if (existing) { return existing; } + + const role = new Role(scope as Construct, id, { + assumedBy: new ServicePrincipal('iot.amazonaws.com'), + }); + + policyStatements.forEach(role.addToPolicy.bind(role)); + + return role; +} + +export function topicArn(scope: IConstruct, topic: string, region?: string, account?: string): string { + const stack = Stack.of(scope); + return Arn.format({ + region: region || stack.region, + account: account || stack.account, + service: 'iot', + resource: 'topic', + resourceName: topic, + }, stack); +} diff --git a/packages/@aws-cdk/aws-iot/package.json b/packages/@aws-cdk/aws-iot/package.json index baec64baf8142..b1461b23d1dcc 100644 --- a/packages/@aws-cdk/aws-iot/package.json +++ b/packages/@aws-cdk/aws-iot/package.json @@ -73,23 +73,54 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", - "pkglint": "0.0.0" + "pkglint": "0.0.0", + "nodeunit-shim": "0.0.0" }, "dependencies": { "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", "constructs": "^3.2.0" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", "constructs": "^3.2.0" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, + "awslint": { + "exclude": [ + "docs-public-apis:@aws-cdk/aws-iot.TopicRule.fromReceiptRuleName", + "docs-public-apis:@aws-cdk/aws-iot.TopicRule.renderActions", + "no-unused-type:@aws-cdk/aws-iot.TopicRuleAttributes", + "props-physical-name:@aws-cdk/aws-iot.TopicRuleProps", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionProperty.cloudwatchAlarm", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleAttributes.ruleName", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleAttributes.topicRuleArn", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.cloudwatchAlarm", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.cloudwatchAlarmMetric", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.dynamoDB", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.dynamoDBv2", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.elasticsearch", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.firehose", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.http", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.iotAnalytics", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.iotSiteWise", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.kinesis", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.lambda", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.republish", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.s3", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.sns", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.sqs", + "props-default-doc:@aws-cdk/aws-iot.TopicRuleActionConfig.stepFunctions" + ] + }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-iot/test/iot.test.ts b/packages/@aws-cdk/aws-iot/test/iot.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-iot/test/iot.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -}); diff --git a/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts b/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts new file mode 100644 index 0000000000000..da085a478faf5 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts @@ -0,0 +1,205 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Role, ServicePrincipal } from '@aws-cdk/aws-iam'; +import { App, Stack } from '@aws-cdk/core'; +import { nodeunitShim, Test } from 'nodeunit-shim'; +import { TopicRule, ITopicRuleAction, TopicRuleActionConfig } from '../lib'; + +nodeunitShim({ + 'can create topic rules'(test: Test) { + // GIVEN + const stack = new Stack(); + // WHEN + new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + actions: [new DummyAction()], + }); + // THEN + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [{ + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + })); + test.done(); + }, + 'can add role after construction'(test: Test) { + // GIVEN + const stack = new Stack(); + // WHEN + const rule = new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + }); + + rule.addAction(new DummyAction()); + + // THEN + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [{ + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + })); + test.done(); + }, + 'from topic rule arn'(test: Test) { + const stack = new Stack(); + const topicRuleArn = 'arn:aws:iot:::rule/MyTopicRule'; + let topic = TopicRule.fromTopicRuleArn(stack, 'ImportedTopic', topicRuleArn ); + test.deepEqual(topic.ruleName, 'MyTopicRule'); + test.done(); + }, + 'can construct with error action'(test: Test) { + // GIVEN + const stack = new Stack(); + // WHEN + const rule = new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + errorAction: new DummyAction(), + }); + + rule.addAction(new DummyAction()); + + // THEN + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [{ + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }], + AwsIotSqlVersion: '2015-10-08', + ErrorAction: { + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }, + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + })); + test.done(); + }, + 'can add error action after construction'(test: Test) { + // GIVEN + const stack = new Stack(); + // WHEN + const rule = new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + actions: [new DummyAction()], + }); + + rule.addErrorAction(new DummyAction()); + + // THEN + expect(stack).to(haveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [{ + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }], + AwsIotSqlVersion: '2015-10-08', + ErrorAction: { + Republish: { + RoleArn: 'arn:iam::::role/MyRole', + Topic: 'topic/subtopic', + }, + }, + RuleDisabled: false, + Sql: 'SELECT * FROM \'topic/subtopic\'', + }, + })); + test.done(); + }, + 'throws when not given actions'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app); + // WHEN + new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + }); + test.throws(() => app.synth(), /Topic invalid/); + test.done(); + }, + 'grant publish to topic arn'(test: Test) { + const stack = new Stack(); + const role = new Role(stack, 'MyRole', { + assumedBy: new ServicePrincipal('iot.amazonaws.com'), + }); + const topic = new TopicRule(stack, 'IotTopicRule', { + sql: 'SELECT * FROM \'topic/subtopic\'', + actions: [new DummyAction()], + }); + topic.grantPublish(role, 'coffee/shops'); + expect(stack).to(haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + }, + ], + Version: '2012-10-17', + }, + })); + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iot:Publish', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iot:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':topic/coffee/shops', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyRoleDefaultPolicyA36BE1DD', + Roles: [{ Ref: 'MyRoleF48FFE04' }], + })); + test.done(); + }, +}); + +class DummyAction implements ITopicRuleAction { + public bind(): TopicRuleActionConfig { + return { + republish: { + roleArn: 'arn:iam::::role/MyRole', + topic: 'topic/subtopic', + }, + }; + }; +}; + diff --git a/packages/@aws-cdk/aws-iot/test/util.test.ts b/packages/@aws-cdk/aws-iot/test/util.test.ts new file mode 100644 index 0000000000000..74f4e4b4e61a1 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/util.test.ts @@ -0,0 +1,12 @@ +import { nodeunitShim, Test } from 'nodeunit-shim'; +import { parseRuleName } from '../lib/util'; + +nodeunitShim({ + ruleNameFromArn: { + 'produce rule name from topic rule arn'(test: Test) { + const topicRuleArn = 'arn:aws:iot:::rule/hello'; + test.deepEqual(parseRuleName(topicRuleArn), 'hello'); + test.done(); + }, + }, +}); diff --git a/packages/@aws-cdk/cloudformation-include/package.json b/packages/@aws-cdk/cloudformation-include/package.json index 21af2739ac8b3..fe282a3adf876 100644 --- a/packages/@aws-cdk/cloudformation-include/package.json +++ b/packages/@aws-cdk/cloudformation-include/package.json @@ -143,6 +143,7 @@ "@aws-cdk/aws-imagebuilder": "0.0.0", "@aws-cdk/aws-inspector": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot-actions": "0.0.0", "@aws-cdk/aws-iot1click": "0.0.0", "@aws-cdk/aws-iotanalytics": "0.0.0", "@aws-cdk/aws-iotevents": "0.0.0", @@ -292,6 +293,7 @@ "@aws-cdk/aws-imagebuilder": "0.0.0", "@aws-cdk/aws-inspector": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot-actions": "0.0.0", "@aws-cdk/aws-iot1click": "0.0.0", "@aws-cdk/aws-iotanalytics": "0.0.0", "@aws-cdk/aws-iotevents": "0.0.0", diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index 3f4b9857b9231..0e5528f994db1 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -193,6 +193,7 @@ "@aws-cdk/aws-imagebuilder": "0.0.0", "@aws-cdk/aws-inspector": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot-actions": "0.0.0", "@aws-cdk/aws-iot1click": "0.0.0", "@aws-cdk/aws-iotanalytics": "0.0.0", "@aws-cdk/aws-iotevents": "0.0.0", diff --git a/packages/decdk/package.json b/packages/decdk/package.json index c7ef09f0e0036..80798d432a9b3 100644 --- a/packages/decdk/package.json +++ b/packages/decdk/package.json @@ -119,6 +119,7 @@ "@aws-cdk/aws-imagebuilder": "0.0.0", "@aws-cdk/aws-inspector": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot-actions": "0.0.0", "@aws-cdk/aws-iot1click": "0.0.0", "@aws-cdk/aws-iotanalytics": "0.0.0", "@aws-cdk/aws-iotevents": "0.0.0", diff --git a/packages/monocdk/package.json b/packages/monocdk/package.json index 2978fe7a2e8d0..70c9f9622621a 100644 --- a/packages/monocdk/package.json +++ b/packages/monocdk/package.json @@ -198,6 +198,7 @@ "@aws-cdk/aws-imagebuilder": "0.0.0", "@aws-cdk/aws-inspector": "0.0.0", "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot-actions": "0.0.0", "@aws-cdk/aws-iot1click": "0.0.0", "@aws-cdk/aws-iotanalytics": "0.0.0", "@aws-cdk/aws-iotevents": "0.0.0",