Skip to content

Commit b97a907

Browse files
author
ljacobsson
committed
new: EventBridge Pipes support 🎉
1 parent c5ed5dc commit b97a907

File tree

9 files changed

+5500
-6753
lines changed

9 files changed

+5500
-6753
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ For AWS events, such as `aws.codepipeline` it's already enabled, but for custom
1717

1818
![Demo](https://github.com/mhlabs/evb-cli/raw/master/images/demo.gif)
1919

20+
### Generate EventBridge Pipes connections
21+
[EventBridge Pipes](https://aws.amazon.com/eventbridge/pipes/) was one of the more exciting serverless announcements at re:Invent 2022. It lets you create a one-to-one mapping between a source and a target service so you can build event driven applications with less Lambda glue functions.
22+
23+
`evb pipes` helps you create pipes between resources in your CloudFormation/SAM template. Although the [AWS::Pipes::Pipe](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-pipes-pipe.html) resource is easy to compose, the IAM role that goes with it isn't that straight forward.
24+
25+
Use the `--guided` flag to get prompted for all optional parameters.
26+
27+
![Demo](https://github.com/mhlabs/evb-cli/raw/master/images/demo-pipes.gif)
28+
2029
### To generate an EventBridge InputTransformer object:
2130
[Input transformers](https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-input-transformer-tutorial.html) are useful when you only want a small portion of the event sent to your target. This command helps you navigate the JSON payload and generate the [InputTransformer CloudFormation object](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-events-rule-inputtransformer.html)
2231

images/demo-pipes.gif

1.4 MB
Loading

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require("./src/commands/replay-dead-letter");
1414
require("./src/commands/local");
1515
require("./src/commands/code-binding");
1616
require("./src/commands/api-destination");
17+
require("./src/commands/pipes");
1718

1819
program.version(package.version, "-v, --vers", "output the current version");
1920

package-lock.json

Lines changed: 3423 additions & 6750 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mhlabs/evb-cli",
3-
"version": "1.1.46",
3+
"version": "1.1.47",
44
"description": "A package for building EventBridge/CloudWatch Events patterns",
55
"main": "index.js",
66
"scripts": {
@@ -14,7 +14,7 @@
1414
"license": "ISC",
1515
"dependencies": {
1616
"@mhlabs/aws-sdk-sso": "^0.0.16",
17-
"aws-sdk": "^2.853.0",
17+
"aws-sdk": "^2.1268.0",
1818
"axios": "^0.21.4",
1919
"cli-spinner": "^0.2.10",
2020
"commander": "^4.1.1",
@@ -37,7 +37,7 @@
3737
},
3838
"homepage": "https://github.com/mhlabs/evb-cli#readme",
3939
"devDependencies": {
40-
"jest": "^25.1.0",
40+
"jest": "^29.3.1",
4141
"y18n": ">=4.0.1"
4242
},
4343
"bin": {

src/commands/pipes/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const program = require("commander");
2+
const pipeBuilder = require("./pipe-builder");
3+
4+
program
5+
.command("pipes")
6+
.alias("pi")
7+
.option("-p, --profile [profile]", "AWS profile to use")
8+
.option("-t, --template [template]", "Path to template file", "template.yaml")
9+
.option("-g, --guided", "Run in guided mode - prompts for all optional parameters")
10+
.option(
11+
"--region [region]",
12+
"The AWS region to use. Falls back on AWS_REGION environment variable if not specified"
13+
)
14+
.description("Connects two compatible resources in your template via EventBridge Pipes")
15+
.action(async (cmd) => {
16+
await pipeBuilder.build(
17+
cmd
18+
);
19+
});

src/commands/pipes/pipe-builder.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
const supportedTypes = require("./pipes-config.json");
2+
const pipesSchema = require("./pipes-cfn-schema.json")
3+
const templateParser = require("../shared/template-parser");
4+
const inputUtil = require("../shared/input-util")
5+
let cmd;
6+
async function build(command) {
7+
cmd = command;
8+
const templateName = cmd.template;
9+
const template = templateParser.load(templateName, true);
10+
if (!template) {
11+
throw new Error(`Template "${templateName}" does not exist.`);
12+
}
13+
const compatibleSources = await getSources(template);
14+
const sourceChoices = compatibleSources.map(p => { return { name: `[${template.Resources[p].Type}] ${p}`, value: { name: p, type: template.Resources[p].Type } } }).sort((a, b) => a.name > b.name ? 1 : -1);
15+
let source = await inputUtil.selectFrom([...sourceChoices, "Not templated"], "Select source", true);
16+
17+
if (source === "Not templated") {
18+
const allTypes = supportedTypes.filter(p => !p.Type.includes("Serverless") && p.Source).map(p => p.Type)
19+
const type = await inputUtil.selectFrom(allTypes, "Select resource type", true);
20+
arn = await inputUtil.text("Enter ARN")
21+
source = { type: type, arn: arn, name: type.split(":")[1] }
22+
}
23+
const sourceConfig = supportedTypes.find(p => p.Type === source.type);
24+
const sourceObj = await buildParametersForSide(sourceConfig.SourceSchemaName);
25+
26+
const compatibleTargets = await getTargets(template, source);
27+
const targetChoices = compatibleTargets.map(p => { return { name: `[${template.Resources[p].Type}] ${p}`, value: { name: p, type: template.Resources[p].Type } } }).sort((a, b) => a.name > b.name ? 1 : -1);
28+
let target = await inputUtil.selectFrom([...targetChoices, "Not templated"], "Select target", true);
29+
if (target === "Not templated") {
30+
const allTypes = supportedTypes.filter(p => !p.Type.includes("Serverless") && p.Target).map(p => p.Type)
31+
const type = await inputUtil.selectFrom(allTypes, "Select resource type", true);
32+
arn = await inputUtil.text("Enter ARN")
33+
target = { type: type, arn: arn, name: type.split(":")[1] }
34+
}
35+
const targetConfig = supportedTypes.find(p => p.Type === target.type);
36+
const targetObj = await buildParametersForSide(targetConfig.TargetSchemaName);
37+
const sourcePropertyName = sourceConfig.SourceSchemaName.replace("PipeSource", "");
38+
const targetPropertyName = targetConfig.TargetSchemaName.replace("PipeTarget", "");
39+
const pipeName = `${source.name}To${target.name}Pipe`;
40+
template.Resources[pipeName] = {
41+
Type: "AWS::Pipes::Pipe",
42+
Properties: {
43+
Name: {
44+
"Fn::Sub": "${AWS::StackName}-" + pipeName
45+
},
46+
RoleArn: { "Fn::GetAtt": [`${pipeName}Role`, "Arn"] },
47+
Source: source.arn || JSON.parse(JSON.stringify(sourceConfig.ArnGetter).replace("#RESOURCE_NAME#", source.name)),
48+
Target: target.arn || JSON.parse(JSON.stringify(targetConfig.ArnGetter).replace("#RESOURCE_NAME#", target.name))
49+
}
50+
}
51+
if (Object.keys(sourceObj).length)
52+
template.Resources[pipeName].Properties["SourceParameters"] = { [sourcePropertyName]: sourceObj };
53+
54+
if (Object.keys(targetObj).length)
55+
template.Resources[pipeName].Properties["TargetParameters"] = { [targetPropertyName]: targetObj };
56+
57+
58+
const role = {
59+
Type: "AWS::IAM::Role",
60+
Properties: {
61+
AssumeRolePolicyDocument: {
62+
Version: "2012-10-17",
63+
Statement: [
64+
{
65+
Effect: "Allow",
66+
Principal: {
67+
Service: [
68+
"pipes.amazonaws.com"
69+
]
70+
},
71+
Action: [
72+
"sts:AssumeRole"
73+
]
74+
}
75+
]
76+
},
77+
Policies: [
78+
{
79+
PolicyName: "LogsPolicy",
80+
PolicyDocument: {
81+
Version: "2012-10-17",
82+
Statement: [
83+
{
84+
Effect: "Allow",
85+
Action: [
86+
"logs:CreateLogStream",
87+
"logs:CreateLogGroup",
88+
"logs:PutLogEvents"],
89+
Resource: "*"
90+
},
91+
],
92+
}
93+
}
94+
]
95+
}
96+
};
97+
sourceConfig.SourcePolicy.Statement[0].Resource = source.arn || JSON.parse(JSON.stringify((sourceConfig.SourcePolicy.Statement[0].Resource || sourceConfig.ArnGetter)).replace(/#RESOURCE_NAME#/g, source.name));
98+
targetConfig.TargetPolicy.Statement[0].Resource = target.arn || JSON.parse(JSON.stringify((targetConfig.TargetPolicy.Statement[0].Resource || targetConfig.ArnGetter)).replace(/#RESOURCE_NAME#/g, target.name));
99+
role.Properties.Policies.push({
100+
PolicyName: "SourcePolicy",
101+
PolicyDocument: sourceConfig.SourcePolicy
102+
}, {
103+
PolicyName: "TargetPolicy",
104+
PolicyDocument: targetConfig.TargetPolicy
105+
})
106+
template.Resources[`${pipeName}Role`] = role
107+
templateParser.saveTemplate();
108+
}
109+
110+
async function buildParametersForSide(definitionName) {
111+
const schema = pipesSchema.definitions[definitionName];
112+
const obj = {};
113+
if (schema) {
114+
await buildParameters(obj, schema);
115+
}
116+
117+
return obj;
118+
}
119+
120+
async function buildParameters(obj, schema, propName, prefix, isRequired) {
121+
prefix = prefix || "";
122+
let settings = [];
123+
if (schema.type === "object") {
124+
settings.push(...Object.keys(schema.properties));
125+
} else {
126+
settings = [schema];
127+
}
128+
for (const setting of settings) {
129+
if (!propName) propName = setting;
130+
isRequired = isRequired || schema.required && schema.required.includes(setting);
131+
let optionalityString = "(leave blank to skip)"
132+
if (isRequired) {
133+
optionalityString = "(required)";
134+
} else if (!cmd.guided) {
135+
continue;
136+
}
137+
let validationString = "";
138+
const property = schema.properties && schema.properties[setting] || setting;
139+
if (property.maximum && property.minimum) {
140+
validationString += ` (${property.minimum} - ${property.maximum})`;
141+
}
142+
143+
if (property["$ref"]) {
144+
const name = property.$ref.split("/").slice(-1)[0];
145+
obj[setting] = obj[setting] || {};
146+
const type = await buildParameters(obj[setting], pipesSchema.definitions[name], setting, prefix + setting + ".", isRequired);
147+
if (type === "enum") {
148+
obj[setting] = obj[setting][setting];
149+
}
150+
} else if (property.enum) {
151+
if (!isRequired) {
152+
property.enum.push("Skip");
153+
}
154+
155+
const input = await inputUtil.selectFrom(property.enum, `Select value for ${propName}`, true)
156+
if (input === "Skip") {
157+
continue;
158+
}
159+
obj[propName] = input;
160+
return "enum";
161+
} else if (property.type === "array") {
162+
const input = await inputUtil.text(`Enter values for ${prefix}${setting}${validationString}. Seperate with comma. ${optionalityString}`);
163+
if (input) {
164+
obj[setting] = input.split(",").map(x => x.trim());
165+
}
166+
}
167+
else {
168+
let input = await inputUtil.text(`Enter value for ${prefix}${setting}${validationString} ${optionalityString}`)
169+
if (input) {
170+
if (property.type === "integer") {
171+
input = parseInt(input);
172+
} else if (property.type === "boolean") {
173+
input = input.toLowerCase() === "true";
174+
}
175+
obj[setting] = input;
176+
}
177+
}
178+
}
179+
}
180+
181+
async function getSources(template) {
182+
const types = supportedTypes.map(p => p.Type)
183+
return Object.keys(template.Resources).filter(p => types.includes(template.Resources[p].Type) && supportedTypes.find(q => q.Type === template.Resources[p].Type).Source)
184+
}
185+
186+
async function getTargets(template, source) {
187+
const types = supportedTypes.map(p => p.Type)
188+
return Object.keys(template.Resources).filter(p => types.includes(template.Resources[p].Type) && supportedTypes.find(q => q.Type === template.Resources[p].Type).Target && p !== source)
189+
}
190+
191+
192+
module.exports = {
193+
build,
194+
};

0 commit comments

Comments
 (0)