├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── assets ├── logo.svg ├── screenshot.png └── screenshot_test-event.png ├── commands.go ├── data_sources.go ├── eventbridge.go ├── flags.go ├── go.mod ├── go.sum ├── integration_test.go ├── main.go ├── sqs.go ├── sqs_test.go └── testdata ├── event.json ├── event_ci_fail.json ├── event_ci_success.json ├── eventpattern.json └── template.yaml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: code scanning 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 6 * * 5' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # Initializes the CodeQL tools for scanning. 23 | - name: Initialize CodeQL 24 | uses: github/codeql-action/init@v1 25 | # Override language selection by uncommenting this and choosing your languages 26 | # with: 27 | # languages: go, javascript, csharp, python, cpp, java 28 | 29 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 30 | # If this step fails, then you should remove it and run the build manually (see below) 31 | - name: Autobuild 32 | uses: github/codeql-action/autobuild@v1 33 | 34 | # ℹ️ Command-line programs to run using the OS shell. 35 | # 📚 https://git.io/JvXDl 36 | 37 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 38 | # and modify them (or add more) to build your code if your project 39 | # uses a compiled language 40 | 41 | #- run: | 42 | # make bootstrap 43 | # make release 44 | 45 | - name: Perform CodeQL Analysis 46 | uses: github/codeql-action/analyze@v1 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v1 18 | with: 19 | go-version: 1.20.x 20 | 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@master 23 | with: 24 | version: latest 25 | args: release --rm-dist 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | 4 | on: 5 | push: 6 | branches: 7 | - 'master' 8 | pull_request: 9 | branches: 10 | - '*' 11 | 12 | jobs: 13 | test: 14 | name: test 15 | strategy: 16 | matrix: 17 | go: [1.18, 1.19, "1.20"] 18 | platform: [ubuntu-latest, macos-latest, windows-latest] 19 | runs-on: ${{ matrix.platform }} 20 | steps: 21 | - name: install Go 22 | uses: actions/setup-go@master 23 | with: 24 | go-version: ${{ matrix.go }} 25 | 26 | - name: checkout code 27 | uses: actions/checkout@master 28 | 29 | - name: test 30 | run: go test ./... -v -cover 31 | 32 | integration-test: 33 | name: integration test 34 | needs: test 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: install Go 38 | uses: actions/setup-go@master 39 | with: 40 | go-version: 1.20.x 41 | 42 | - name: checkout code 43 | uses: actions/checkout@master 44 | 45 | - name: Configure AWS credentials 46 | uses: aws-actions/configure-aws-credentials@v1 47 | with: 48 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 49 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 50 | aws-region: ${{ secrets.AWS_DEFAULT_REGION }} 51 | 52 | - name: test 53 | run: go test -tags=integration -v 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | eventbridge-cli 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | project_name: eventbridge-cli 3 | 4 | builds: 5 | - binary: eventbridge-cli 6 | env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - darwin 11 | - windows 12 | goarch: 13 | - amd64 14 | ldflags: -s -w 15 | 16 | archives: 17 | - format: tar.gz 18 | wrap_in_directory: true 19 | format_overrides: 20 | - goos: windows 21 | format: zip 22 | # remove README and LICENSE 23 | files: 24 | - none* 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Matteo 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![logo](assets/logo.svg) eventbridge-cli 2 | [![Release](https://img.shields.io/github/release/spezam/eventbridge-cli.svg)](https://github.com/spezam/eventbridge-cli/releases/latest) 3 | ![Actions Status](https://github.com/spezam/eventbridge-cli/workflows/test/badge.svg) 4 | ![Code Scanning](https://github.com/spezam/eventbridge-cli/workflows/code%20scanning/badge.svg) 5 | 6 | Amazon EventBridge is a serverless event bus that makes it easy to connect applications together using data from your own applications, integrated Software-as-a-Service (SaaS) applications, and AWS services. 7 | 8 | Eventbridge-cli is a tool to listen to an EventBus events. Useful for debugging, event pattern testing, CI pipelines integration. 9 | ``` 10 | EventBus --> EventBrige Rule --> SQS <-- poller 11 | ``` 12 | 13 | Features: 14 | - Listen to Event Bus messages 15 | - Filter messages by event pattern 16 | - Read event pattern from cli, file or SAM template 17 | - Authentication via profile or env variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) 18 | - Pretty JSON output 19 | - CI mode 20 | - Dry event test 21 | - ... 22 | 23 | ![screenshot](assets/screenshot.png) 24 | 25 | ### Install from releases binary: 26 | ``` 27 | wget https://github.com/spezam/eventbridge-cli/releases/download//eventbridge-cli__darwin_amd64.tar.gz 28 | tar xvfz eventbridge-cli__darwin_amd64.tar.gz 29 | mv eventbridge-cli /somewhere/in/PATH 30 | ``` 31 | ### with go install 32 | ``` 33 | GO111MODULE=on go install github.com/spezam/eventbridge-cli 34 | ``` 35 | ### or build from source: 36 | ``` 37 | git clone https://github.com/spezam/eventbridge-cli.git 38 | cd eventbridge-cli 39 | go build 40 | ``` 41 | 42 | ## Standard mode 43 | ### Flags: 44 | ``` 45 | NAME: 46 | eventbridge-cli - AWS EventBridge cli 47 | 48 | USAGE: 49 | eventbridge [global options] command [command options] [arguments...] 50 | 51 | VERSION: 52 | 1.0.0 53 | 54 | AUTHOR: 55 | matteo ridolfi 56 | 57 | COMMANDS: 58 | ci AWS EventBridge cli - CI mode 59 | help, h Shows a list of commands or help for one command 60 | 61 | GLOBAL OPTIONS: 62 | --profile value, -p value AWS profile (default: "default") [$AWS_PROFILE] 63 | --region value, -r value AWS region [$AWS_DEFAULT_REGION] 64 | --eventbusname value, -b value EventBridge Bus Name (default: "default") 65 | --eventpattern value, -e value EventBridge event pattern. Can be prefixed by 'file://' or 'sam://' (default: "{\"source\": [{\"anything-but\": [\"eventbridge-cli\"]}]}") 66 | --prettyjson, -j Pretty JSON output (default: false) 67 | --help, -h show help (default: false) 68 | --version, -v print the version (default: false) 69 | ``` 70 | 71 | ### Usage 72 | Authenticate via environment variable: 73 | ```sh 74 | AWS_PROFILE=myawsprofile eventbridge-cli 75 | AWS_DEFAULT_REGION=eu-north-1 AWS_ACCESS_KEY_ID=zzZZ AWS_SECRET_ACCESS_KEY=abbC eventbridge-cli 76 | ``` 77 | 78 | Authenticate via cli flags: 79 | ```sh 80 | eventbridge-cli -p myawsprofile 81 | eventbridge-cli -p myawsprofile -r eu-north-1 82 | ``` 83 | 84 | Event pattern can be specified directly in the cli `-e '{}'`, using a JSON file `-e file://...` or from a SAM template `-e sam:///`: 85 | ```sh 86 | eventbridge-cli -p myawsprofile -j \ 87 | -b fishnchips-eventbus \ 88 | -e '{"source":["gamma"],"detail":{"channel":["web"]}}' 89 | 90 | eventbridge-cli -p myawsprofile -j \ 91 | -b fishnchips-eventbus \ 92 | -e file://testdata/eventpattern.json 93 | 94 | eventbridge-cli -p myawsprofile -j \ 95 | -b fishnchips-eventbus \ 96 | -e sam://testdata/template.yaml/BetaFunction 97 | ``` 98 | 99 | 100 | ## CI mode 101 | CI mode can be used to perform integration testing in an automated way. 102 | 103 | Given an event pattern (*global* flag `-e`) and an input event (*ci* flag `-i`), verifies the message goes through the event bus within timeout (*ci* flag `-t`). 104 | 105 | Note: global flags are position sensitive and can't be used under 'ci' command. For example: 106 | ```sh 107 | eventbridge-cli -j ci -t 20 108 | ``` 109 | 110 | ### Flags: 111 | ``` 112 | NAME: 113 | eventbridge-cli ci - AWS EventBridge cli - CI mode 114 | 115 | USAGE: 116 | eventbridge-cli ci [command options] [arguments...] 117 | 118 | DESCRIPTION: 119 | run eventbridge-cli in CI mode 120 | 121 | OPTIONS: 122 | --timeout value, -t value CI timeout in seconds (default: 12) 123 | --inputevent value, -i value Input event. Can be omitted if coming from other sources or prefixed by 'file://' 124 | --help, -h show help (default: false) 125 | ``` 126 | 127 | ### Usage 128 | Event pattern and input event from cli: 129 | ```sh 130 | eventbridge-cli -p myawsprofile -j \ 131 | -e '{"source": ["beta"]}' \ 132 | ci -i '{"source":"beta", "detail":"{\"channel\":\"web\"}", "detail-type": "poc"}' 133 | ``` 134 | 135 | Use the `-t` flag to specify timeout: 136 | ```sh 137 | eventbridge-cli -p myawsprofile -j \ 138 | -e '{"source": ["beta"]}' \ 139 | ci -i '{"source":"beta", "detail":"{\"channel\":\"web\"}", "detail-type": "poc"}' \ 140 | -t 20 141 | ``` 142 | 143 | Event pattern and input event from file: 144 | ```sh 145 | eventbridge-cli -p myawsprofile -j \ 146 | -e file://testdata/eventpattern.json \ 147 | ci -i file://testdata/event_ci_success.json 148 | 149 | # failing CI 150 | eventbridge-cli -p myawsprofile -j \ 151 | -e file://testdata/eventpattern.json \ 152 | ci -i file://testdata/event_ci_fail.json 153 | ``` 154 | 155 | Event pattern from SAM template, BetaFunction lambda function: 156 | ```sh 157 | eventbridge-cli -p myawsprofile -j \ 158 | -e sam://testdata/template.yaml/BetaFunction \ 159 | ci -i file://testdata/event_ci_success.json 160 | ``` 161 | 162 | Listen to events from any other source (lambda, aws cli, sam local, ...) 163 | ```sh 164 | eventbridge-cli -p myawsprofile -j \ 165 | -e file://testdata/eventpattern.json \ 166 | ci 167 | ``` 168 | 169 | ## Test Event Rule 170 | Test event payloads against deployed event rules on a specific eventbus. 171 | 172 | ![screenshot](assets/screenshot_test-event.png) 173 | 174 | 175 | Given an optional bus (*global* flag `-b`) an event rule (*test-event* flag `-e`) and an input event (*test-event* flag `-i`), verifies the payload will match the rule. 176 | Rule is treated as prefix, so can be a subset of the rule name (ie. `-e fish` will test all rules starting with `fish`) 177 | 178 | Note: global flags are position sensitive and can't be used under 'test-event' command. For example: 179 | ```sh 180 | eventbridge-cli -b somebus test-event 181 | ``` 182 | 183 | ### Flags: 184 | ``` 185 | NAME: 186 | eventbridge-cli test-event - AWS EventBridge test-event 187 | 188 | USAGE: 189 | eventbridge-cli test-event [command options] [arguments...] 190 | 191 | DESCRIPTION: 192 | run eventbridge-cli to test an event against a deployed event pattern 193 | 194 | OPTIONS: 195 | --eventrule value, -e value EventBridge rule name. Can be a prefix 196 | --inputevent value, -i value Input event. Can be prefixed by 'file://' or omitted if coming from other sources 197 | --help, -h show help (default: false) 198 | ``` 199 | 200 | ### Usage 201 | ```sh 202 | eventbridge-cli -p myawsprofile -b fishnchips-eventbus \ 203 | test-event -i file://testdata/event.json -e fishnch 204 | 205 | eventbridge-cli -p myawsprofile -b fishnchips-eventbus \ 206 | test-event \ 207 | -i file://testdata/event.json \ 208 | -e fishnchips-eventbridge-BetaFunctionEventListener 209 | 210 | eventbridge-cli -p myawsprofile -b fishnchips-eventbus \ 211 | test-event \ 212 | -i '{"version":"0", "id": "cwe-test", "account": "123456789012", "region": "eu-north-1", "time": "2017-04-11T20:11:04Z", "source": ["beta"], "detail": {"channel": ["web"]}, "detail-type": ["poc.succeeded"]}' \ 213 | -e fishnchips-eventbridge-BetaFunctionEventListener 214 | ``` 215 | 216 | 217 | 218 | ### Content-based Filtering with Event Patterns reference: 219 | https://docs.aws.amazon.com/eventbridge/latest/userguide/content-filtering-with-event-patterns.html 220 | 221 | Here is a summary of all the comparison operators available in EventBridge: 222 | 223 | | Comparison | Example | Rule syntax | 224 | | ------------ |------------------ | --------------------| 225 | | Null | UserID is null | "UserID": [ null ] | 226 | | Empty | LastName is empty | "LastName": [""] | 227 | | Equals | Name is "Alice" | "Name": [ "Alice" ] | 228 | | And | Location is "New York" and Day is "Monday" | "Location": [ "New York" ], "Day": [ "Monday" ] | 229 | | Or | PaymentType is "Credit" or "Debit" | "PaymentType": [ "Credit", "Debit" ] | 230 | | Not | Weather is anything but "Raining" | "Weather": [{ "anything-but": [ "Raining" ] }] | 231 | | Numeric (equals) | Price is 100 | "Price": [{ "numeric": [ "=", 100 ] }] | 232 | | Numeric (range) | Price is more than 10, and less than or equal to 20 | "Price": [{ "numeric": [ ">", 10, "<=", 20 ] }] | 233 | | Exists | ProductName exists | "ProductName": [{ "exists": true }] | 234 | | Does not exist | ProductName does not exist | "ProductName": [{ "exists": false }] | 235 | | Begins with | Region is in the US | "Region": [{ "prefix": "us-" }] | 236 | 237 | 238 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spezam/eventbridge-cli/1ddcb7a1b83489684a672dc38c55ec49ac0f6380/assets/screenshot.png -------------------------------------------------------------------------------- /assets/screenshot_test-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spezam/eventbridge-cli/1ddcb7a1b83489684a672dc38c55ec49ac0f6380/assets/screenshot_test-event.png -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var commands = []*cli.Command{ 6 | { 7 | Name: "ci", 8 | Usage: "AWS EventBridge cli - CI mode", 9 | Description: "run eventbridge-cli in CI mode", 10 | Flags: flagsCI, 11 | Action: run, 12 | }, 13 | { 14 | Name: "test-event", 15 | Usage: "AWS EventBridge test-event", 16 | Description: "run eventbridge-cli to test an event against a deployed event rule pattern", 17 | Flags: flagsTestEventPattern, 18 | Action: runTestEventPattern, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /data_sources.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "path" 8 | "strings" 9 | 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | type samTemplate struct { 14 | Resources map[string]struct { 15 | Type string `yaml:"Type"` 16 | Properties struct { 17 | FunctionName string `yaml:"FunctionName"` 18 | Events map[string]struct { 19 | Type string `yaml:"Type"` 20 | Properties struct { 21 | EventBusName string `yaml:"EventBusName,omitempty"` 22 | InputPath string `yaml:"InputPath,omitempty"` 23 | Pattern interface{} `yaml:"Pattern,omitempty"` 24 | } `yaml:"Properties"` 25 | } `yaml:"Events"` 26 | } `yaml:"Properties"` 27 | } `yaml:"Resources"` 28 | } 29 | 30 | // file://eventpattern.json 31 | func dataFromFile(filepath string) (string, error) { 32 | file := strings.Replace(filepath, "file://", "", -1) 33 | 34 | e, err := ioutil.ReadFile(file) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | return string(e), nil 40 | } 41 | 42 | // sam://template.yaml/FunctionName 43 | func dataFromSAM(sampath string) (string, error) { 44 | function := path.Base(sampath) 45 | template := strings.Replace(sampath, "sam://", "", -1) 46 | template = strings.Replace(template, fmt.Sprintf("/%s", function), "", -1) 47 | 48 | e, err := ioutil.ReadFile(template) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | // unmarshal SAM template 54 | b := &samTemplate{} 55 | if err := yaml.Unmarshal([]byte(e), &b); err != nil { 56 | return "", err 57 | } 58 | 59 | // find EventBridgeRule and marshal to JSON 60 | var p []byte 61 | for _, e := range b.Resources[function].Properties.Events { 62 | if e.Type == "EventBridgeRule" { 63 | if p, err = json.Marshal(convertMap(e.Properties.Pattern)); err != nil { 64 | return "", err 65 | } 66 | } 67 | } 68 | 69 | return string(p), nil 70 | } 71 | 72 | // convert map[interface{}]interface{} to map[string]interface{} 73 | func convertMap(i interface{}) interface{} { 74 | switch x := i.(type) { 75 | case map[interface{}]interface{}: 76 | m := map[string]interface{}{} 77 | for k, v := range x { 78 | m[k.(string)] = convertMap(v) 79 | } 80 | return m 81 | 82 | case []interface{}: 83 | for i, v := range x { 84 | x[i] = convertMap(v) 85 | } 86 | } 87 | 88 | return i 89 | } 90 | -------------------------------------------------------------------------------- /eventbridge.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/service/eventbridge" 11 | "github.com/aws/aws-sdk-go-v2/service/eventbridge/types" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | type eventbridgeClient struct { 16 | client *eventbridge.Client 17 | 18 | eventBusName string 19 | } 20 | 21 | func newEventbridgeClient(cfg aws.Config, eventBusName string) *eventbridgeClient { 22 | return &eventbridgeClient{ 23 | client: eventbridge.NewFromConfig(cfg), 24 | eventBusName: eventBusName, 25 | } 26 | } 27 | 28 | func (e *eventbridgeClient) testEventPattern(ctx context.Context, inputEvent, eventRule string) error { 29 | // list eventbridge rules filtered by prefix 30 | resp, err := e.client.ListRules(ctx, &eventbridge.ListRulesInput{ 31 | EventBusName: &e.eventBusName, 32 | NamePrefix: aws.String(eventRule), 33 | }) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if len(resp.Rules) < 1 { 39 | log.Printf("no event rule with prefix: %s", eventRule) 40 | return nil 41 | } 42 | 43 | log.Printf("event rules matching the event:") 44 | for _, r := range resp.Rules { 45 | // skip schedule rules since EventPattern is nil 46 | if r.EventPattern == nil { 47 | continue 48 | } 49 | 50 | res, err := e.client.TestEventPattern(ctx, &eventbridge.TestEventPatternInput{ 51 | Event: aws.String(inputEvent), 52 | EventPattern: r.EventPattern, 53 | }) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if res.Result == false { 59 | log.Printf("%s: %s", *r.Name, color.RedString("✘")) 60 | continue 61 | } 62 | log.Printf("%s: %s", *r.Name, color.GreenString("✔")) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (e *eventbridgeClient) createRule(ctx context.Context, eventPattern string) (string, error) { 69 | res, err := e.client.PutRule(ctx, &eventbridge.PutRuleInput{ 70 | Name: aws.String(namespace + "-" + runID), 71 | Description: aws.String(fmt.Sprintf("[%s] temp rule", namespace)), 72 | EventBusName: aws.String(e.eventBusName), 73 | EventPattern: aws.String(eventPattern), 74 | State: types.RuleStateEnabled, 75 | }) 76 | if err != nil { 77 | log.Printf("eventbridge.CreateRule error: %s", err) 78 | return "", err 79 | } 80 | 81 | return *res.RuleArn, nil 82 | } 83 | 84 | func (e *eventbridgeClient) deleteRule(ctx context.Context) error { 85 | _, err := e.client.DeleteRule(ctx, &eventbridge.DeleteRuleInput{ 86 | EventBusName: aws.String(e.eventBusName), 87 | Force: true, 88 | Name: aws.String(namespace + "-" + runID), 89 | }) 90 | if err != nil { 91 | log.Printf("eventbridge.DeleteRule error: %s", err) 92 | return err 93 | } 94 | 95 | return err 96 | } 97 | 98 | func (e *eventbridgeClient) putEvent(ctx context.Context, event string) error { 99 | log.Printf("putting event: %s", event) 100 | ev := struct { 101 | Source string `json:"source"` 102 | Detail string `json:"detail"` 103 | DetailType string `json:"detail-type"` 104 | }{} 105 | err := json.Unmarshal([]byte(event), &ev) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | resp, err := e.client.PutEvents(ctx, &eventbridge.PutEventsInput{ 111 | Entries: []types.PutEventsRequestEntry{ 112 | { 113 | Source: aws.String(ev.Source), 114 | Detail: aws.String(ev.Detail), 115 | DetailType: aws.String(ev.DetailType), 116 | EventBusName: aws.String(e.eventBusName), 117 | }, 118 | }, 119 | }) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | if resp.FailedEntryCount > 0 { 125 | return fmt.Errorf("%s", *resp.Entries[0].ErrorMessage) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (e *eventbridgeClient) putTarget(ctx context.Context, sqsArn string) error { 132 | _, err := e.client.PutTargets(ctx, &eventbridge.PutTargetsInput{ 133 | Rule: aws.String(namespace + "-" + runID), 134 | EventBusName: aws.String(e.eventBusName), 135 | Targets: []types.Target{ 136 | { 137 | Id: aws.String(namespace + "-" + runID), 138 | Arn: aws.String(sqsArn), 139 | }, 140 | }, 141 | }) 142 | if err != nil { 143 | log.Printf("eventbridge.PutTarget error %s", err) 144 | return err 145 | } 146 | 147 | return err 148 | } 149 | 150 | func (e *eventbridgeClient) removeTarget(ctx context.Context) error { 151 | _, err := e.client.RemoveTargets(ctx, &eventbridge.RemoveTargetsInput{ 152 | Ids: []string{ 153 | namespace + "-" + runID, 154 | }, 155 | Rule: aws.String(namespace + "-" + runID), 156 | EventBusName: aws.String(e.eventBusName), 157 | }) 158 | if err != nil { 159 | log.Printf("eventbridge.RemoveTarget error %s", err) 160 | return err 161 | } 162 | 163 | return err 164 | } 165 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var flags = []cli.Flag{ 10 | &cli.StringFlag{ 11 | Name: "profile", 12 | Aliases: []string{"p"}, 13 | Usage: "AWS profile", 14 | EnvVars: []string{"AWS_PROFILE"}, 15 | }, 16 | &cli.StringFlag{ 17 | Name: "region", 18 | Aliases: []string{"r"}, 19 | Usage: "AWS region", 20 | EnvVars: []string{"AWS_DEFAULT_REGION", "AWS_REGION"}, 21 | }, 22 | &cli.StringFlag{ 23 | Name: "eventbusname", 24 | Aliases: []string{"b"}, 25 | Usage: "EventBridge Bus Name", 26 | Value: "default", 27 | }, 28 | &cli.StringFlag{ 29 | Name: "eventpattern", 30 | Aliases: []string{"e"}, 31 | Usage: "EventBridge event pattern. Can be prefixed by 'file://' or 'sam://'", 32 | Value: fmt.Sprintf(`{"source": [{"anything-but": ["%s"]}]}`, namespace), 33 | }, 34 | &cli.BoolFlag{ 35 | Name: "prettyjson", 36 | Aliases: []string{"j"}, 37 | Usage: "Pretty JSON output", 38 | }, 39 | } 40 | 41 | var flagsCI = []cli.Flag{ 42 | &cli.Int64Flag{ 43 | Name: "timeout", 44 | Aliases: []string{"t"}, 45 | Usage: "CI timeout in seconds", 46 | Value: 12, 47 | }, 48 | &cli.StringFlag{ 49 | Name: "inputevent", 50 | Aliases: []string{"i"}, 51 | Usage: "Input event. Can be prefixed by 'file://' or omitted if coming from other sources", 52 | }, 53 | } 54 | 55 | var flagsTestEventPattern = []cli.Flag{ 56 | &cli.StringFlag{ 57 | Name: "eventrule", 58 | Aliases: []string{"e"}, 59 | Usage: "EventBridge rule name. Can be a prefix", 60 | Required: true, 61 | }, 62 | &cli.StringFlag{ 63 | Name: "inputevent", 64 | Aliases: []string{"i"}, 65 | Usage: "Input event. Can be prefixed by 'file://'", 66 | Required: true, 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spezam/eventbridge-cli 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 7 | github.com/aws/aws-sdk-go-v2 v1.17.7 8 | github.com/aws/aws-sdk-go-v2/config v1.18.19 9 | github.com/aws/aws-sdk-go-v2/service/eventbridge v1.18.7 10 | github.com/aws/aws-sdk-go-v2/service/sqs v1.20.6 11 | github.com/fatih/color v1.15.0 12 | github.com/google/uuid v1.3.0 13 | github.com/stretchr/testify v1.7.0 14 | github.com/urfave/cli/v2 v2.25.1 15 | gopkg.in/yaml.v2 v2.4.0 16 | ) 17 | 18 | require ( 19 | github.com/aws/aws-sdk-go-v2/credentials v1.13.18 // indirect 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect 22 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect 24 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect 29 | github.com/aws/smithy-go v1.13.5 // indirect 30 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 31 | github.com/davecgh/go-spew v1.1.0 // indirect 32 | github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519 // indirect 33 | github.com/mattn/go-colorable v0.1.13 // indirect 34 | github.com/mattn/go-isatty v0.0.18 // indirect 35 | github.com/pmezard/go-difflib v1.0.0 // indirect 36 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 37 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 38 | golang.org/x/sys v0.6.0 // indirect 39 | gopkg.in/yaml.v3 v3.0.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= 2 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= 3 | github.com/aws/aws-sdk-go-v2 v1.17.7 h1:CLSjnhJSTSogvqUGhIC6LqFKATMRexcxLZ0i/Nzk9Eg= 4 | github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= 5 | github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw= 6 | github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY= 7 | github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c= 8 | github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM= 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4= 10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw= 11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 h1:sJLYcS+eZn5EeNINGHSCRAwUJMFVqklwkH36Vbyai7M= 12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo= 13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 h1:1mnRASEKnkqsntcxHaysxwgVoUUp5dkiB+l3llKnqyg= 14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4= 17 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 h1:DWYZIsyqagnWL00f8M/SOr9fN063OEQWn9LLTbdYXsk= 18 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23/go.mod h1:uIiFgURZbACBEQJfqTZPb/jxO7R+9LeoHUFudtIdeQI= 19 | github.com/aws/aws-sdk-go-v2/service/eventbridge v1.18.7 h1:1FzOxMrKHS2gJU8hAU7etJY0NqxAxXjIwh3A9U+GW3Q= 20 | github.com/aws/aws-sdk-go-v2/service/eventbridge v1.18.7/go.mod h1:81fRrGzAOy4lxrZd6kno2FwCzNyPWvheetZZcMCfn4g= 21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 h1:5LHn8JQ0qvjD9L9JhMtylnkcw7j05GDZqM9Oin6hpr0= 22 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU= 23 | github.com/aws/aws-sdk-go-v2/service/sqs v1.20.6 h1:4P/vyx7zCI5yBhlDZ2kwhoLjMJi0X7iR3cxqjNfbego= 24 | github.com/aws/aws-sdk-go-v2/service/sqs v1.20.6/go.mod h1:HQHh1eChX10zDnGmD53WLYk8nPhUKO/JkAUUzDZ530Y= 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI= 26 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY= 27 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4= 28 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s= 29 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM= 30 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI= 31 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= 32 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 33 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 34 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 35 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 36 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 37 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 38 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 39 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 40 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 41 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 42 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 43 | github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519 h1:nqAlWFEdqI0ClbTDrhDvE/8LeQ4pftrqKUX9w5k0j3s= 44 | github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= 45 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 46 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 47 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 48 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 49 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 50 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 51 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 52 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 53 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 55 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 56 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= 60 | github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= 61 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 62 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 63 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 65 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 69 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 70 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 71 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "testing" 8 | 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func Test_integration(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | 18 | eventbusname string 19 | eventpattern string 20 | inputevent string 21 | 22 | err bool 23 | }{ 24 | { 25 | name: "successfull", 26 | eventbusname: "default", 27 | eventpattern: "file://testdata/eventpattern.json", 28 | inputevent: "file://testdata/event_ci_success.json", 29 | err: false, 30 | }, 31 | { 32 | name: "successfull from sam", 33 | eventbusname: "default", 34 | eventpattern: "sam://testdata/template.yaml/BetaFunction", 35 | inputevent: "file://testdata/event_ci_success.json", 36 | err: false, 37 | }, 38 | { 39 | name: "failing", 40 | eventbusname: "default", 41 | eventpattern: "file://testdata/eventpattern.json", 42 | inputevent: "file://testdata/event_ci_fail.json", 43 | err: true, 44 | }, 45 | } 46 | 47 | for _, test := range tests { 48 | t.Run(test.name, func(t *testing.T) { 49 | // override global runID as the value doesn't refresh for each iteration 50 | runID = uuid.New().String() 51 | 52 | app := cli.NewApp() 53 | // flags 54 | set := flag.NewFlagSet("integration-test", 0) 55 | set.String("eventbusname", test.eventbusname, "") 56 | set.String("eventpattern", test.eventpattern, "") 57 | set.Bool("prettyjson", true, "") 58 | // ci flags 59 | set.Int64("timeout", 8, "") 60 | set.String("inputevent", test.inputevent, "") 61 | 62 | c := cli.NewContext(app, set, nil) 63 | c.Command.Name = "ci" 64 | err := run(c) 65 | if test.err { 66 | assert.Error(t, err) 67 | return 68 | } 69 | 70 | assert.NoError(t, err) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // EventBus --> EventBrige Rule --> SQS <-- poller 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go-v2/aws" 15 | "github.com/aws/aws-sdk-go-v2/config" 16 | "github.com/google/uuid" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | var ( 21 | namespace = "eventbridge-cli" 22 | runID = uuid.New().String() 23 | ) 24 | 25 | func main() { 26 | app := &cli.App{ 27 | Name: namespace, 28 | Version: "1.12.0", 29 | Usage: "AWS EventBridge cli", 30 | Authors: []*cli.Author{ 31 | {Name: "matteo ridolfi"}, 32 | }, 33 | Action: run, 34 | Flags: flags, 35 | Commands: commands, 36 | } 37 | 38 | err := app.Run(os.Args) 39 | if err != nil { 40 | log.Fatalf("error: %v", err) 41 | } 42 | } 43 | 44 | func run(c *cli.Context) error { 45 | // AWS config 46 | awsCfg, err := newAWSConfig(c.Context, c.String("profile"), c.String("region")) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // eventbridge client 52 | log.Printf("creating eventBridge client for bus [%s]", c.String("eventbusname")) 53 | ebClient := newEventbridgeClient(awsCfg, c.String("eventbusname")) 54 | 55 | // create temporary eventbridge event rule 56 | eventpattern := c.String("eventpattern") 57 | switch { 58 | case strings.HasPrefix(eventpattern, "file://"): 59 | eventpattern, err = dataFromFile(eventpattern) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | case strings.HasPrefix(eventpattern, "sam://"): 65 | eventpattern, err = dataFromSAM(eventpattern) 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | log.Printf("creating temporary rule on bus [%s]: %s", ebClient.eventBusName, eventpattern) 72 | ruleArn, err := ebClient.createRule(c.Context, eventpattern) 73 | if err != nil { 74 | return err 75 | } 76 | log.Printf("created temporary rule on bus [%s] with arn: %s", ebClient.eventBusName, ruleArn) 77 | 78 | // SQS client 79 | accountID := strings.Split(ruleArn, ":")[4] 80 | queueName := namespace + "-" + runID 81 | sqsClient := newSQSClient(awsCfg, accountID, queueName) 82 | 83 | // SQS queue 84 | if err := sqsClient.createQueue(c.Context, ruleArn); err != nil { 85 | log.Printf("deleting temporary EventBus rule %s...", ruleArn) 86 | _ = ebClient.deleteRule(c.Context) 87 | 88 | return err 89 | } 90 | log.Printf("created temporary SQS queue with URL: %s", sqsClient.queueURL) 91 | 92 | // EventBus --> SQS 93 | if err := ebClient.putTarget(c.Context, sqsClient.arn); err != nil { 94 | log.Printf("deleting temporary SQS queue %s...", sqsClient.queueURL) 95 | _ = sqsClient.deleteQueue(c.Context) 96 | 97 | log.Printf("deleting temporary EventBus rule %s...", ruleArn) 98 | _ = ebClient.deleteRule(c.Context) 99 | 100 | return err 101 | } 102 | log.Printf("linked EventBus --> SQS...") 103 | 104 | // defer cleanup resources 105 | defer func() { 106 | log.Printf("deleting temporary SQS queue %s...", sqsClient.queueURL) 107 | _ = sqsClient.deleteQueue(c.Context) 108 | 109 | log.Printf("removing EventBus target...") 110 | _ = ebClient.removeTarget(c.Context) 111 | 112 | log.Printf("deleting temporary EventBus rule %s...", ruleArn) 113 | _ = ebClient.deleteRule(c.Context) 114 | }() 115 | 116 | // switch between CI and standard modes 117 | switch c.Command.Name { 118 | case "ci": 119 | log.Printf("CI mode") 120 | 121 | signalChan := make(chan os.Signal, 1) 122 | signal.Notify(signalChan, os.Interrupt) 123 | // poll SQS queue undefinitely 124 | go sqsClient.pollQueueCI(c.Context, signalChan, c.Bool("prettyjson"), c.Int64("timeout")) 125 | 126 | // read input event from cli or file 127 | event := c.String("inputevent") 128 | if event != "" { 129 | if strings.HasPrefix(event, "file://") { 130 | event, err = dataFromFile(event) 131 | if err != nil { 132 | return err 133 | } 134 | } 135 | 136 | //time.Sleep(2 * time.Second) // might be needed if too fast 137 | // put event 138 | if err := ebClient.putEvent(c.Context, event); err != nil { 139 | return err 140 | } 141 | } 142 | 143 | select { 144 | case <-time.After(time.Duration(c.Int64("timeout")) * time.Second): 145 | log.Printf("%d seconds timeout reached", c.Int64("timeout")) 146 | 147 | signalChan <- os.Interrupt 148 | return fmt.Errorf("CI failed - didn't receive any event") 149 | 150 | case <-signalChan: 151 | log.Printf("CI successful - message received") 152 | return nil 153 | } 154 | 155 | default: 156 | signalChan := make(chan os.Signal, 1) 157 | signal.Notify(signalChan, os.Interrupt) 158 | // poll SQS queue undefinitely 159 | go sqsClient.pollQueue(c.Context, signalChan, c.Bool("prettyjson")) 160 | 161 | // wait for a SIGINT (ie. CTRL-C) 162 | <-signalChan 163 | log.Printf("received an interrupt, cleaning up...") 164 | } 165 | 166 | return nil 167 | } 168 | 169 | func runTestEventPattern(c *cli.Context) error { 170 | // AWS config 171 | awsCfg, err := newAWSConfig(c.Context, c.String("profile"), c.String("region")) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | // eventbridge client 177 | log.Printf("creating eventBridge client for bus [%s]", c.String("eventbusname")) 178 | ebClient := newEventbridgeClient(awsCfg, c.String("eventbusname")) 179 | 180 | inputevent := c.String("inputevent") 181 | if strings.HasPrefix(inputevent, "file://") { 182 | inputevent, err = dataFromFile(inputevent) 183 | if err != nil { 184 | return err 185 | } 186 | } 187 | 188 | err = ebClient.testEventPattern(c.Context, inputevent, c.String("eventrule")) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | return nil 194 | } 195 | 196 | func newAWSConfig(ctx context.Context, profile, region string) (aws.Config, error) { 197 | var awsCfg aws.Config 198 | var err error 199 | 200 | // use profile if present as cli parameter 201 | if profile != "" { 202 | awsCfg, err = config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(profile)) 203 | } else { 204 | awsCfg, err = config.LoadDefaultConfig(ctx) 205 | } 206 | if err != nil { 207 | return awsCfg, err 208 | } 209 | 210 | // check credentials validity 211 | if _, err := awsCfg.Credentials.Retrieve(ctx); err != nil { 212 | return awsCfg, err 213 | } 214 | 215 | // override profile region if present 216 | if region != "" { 217 | awsCfg.Region = region 218 | } 219 | 220 | return awsCfg, nil 221 | } 222 | -------------------------------------------------------------------------------- /sqs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/TylerBrock/colorjson" 13 | "github.com/aws/aws-sdk-go-v2/aws" 14 | "github.com/aws/aws-sdk-go-v2/service/sqs" 15 | "github.com/aws/aws-sdk-go-v2/service/sqs/types" 16 | ) 17 | 18 | type sqsClient struct { 19 | client sqsClientAPI 20 | 21 | arn string 22 | queueName string 23 | queueURL string 24 | } 25 | 26 | type sqsClientAPI interface { 27 | CreateQueue(ctx context.Context, params *sqs.CreateQueueInput, optFns ...func(*sqs.Options)) (*sqs.CreateQueueOutput, error) 28 | DeleteQueue(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error) 29 | ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) 30 | DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) 31 | } 32 | 33 | func newSQSClient(cfg aws.Config, accountID, queueName string) *sqsClient { 34 | return &sqsClient{ 35 | client: sqs.NewFromConfig(cfg), 36 | arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s", cfg.Region, accountID, queueName), 37 | queueName: queueName, 38 | } 39 | } 40 | 41 | func (s *sqsClient) createQueue(ctx context.Context, ruleArn string) error { 42 | resp, err := s.client.CreateQueue(ctx, &sqs.CreateQueueInput{ 43 | QueueName: aws.String(s.queueName), 44 | Attributes: map[string]string{ 45 | "Policy": fmt.Sprintf(`{ 46 | "Version": "2012-10-17", 47 | "Id": "%s", 48 | "Statement": [{ 49 | "Sid": "Sid1579089564623", 50 | "Effect": "Allow", 51 | "Principal": { 52 | "Service": "events.amazonaws.com" 53 | }, 54 | "Action": "SQS:SendMessage", 55 | "Resource": "%s", 56 | "Condition": { 57 | "ArnEquals": { 58 | "aws:SourceArn": "%s" 59 | } 60 | } 61 | }] 62 | }`, runID, s.arn, ruleArn), 63 | "SqsManagedSseEnabled": "true", 64 | }, 65 | }) 66 | if err != nil { 67 | log.Printf("sqs.CreateQueue error: %s", err) 68 | return err 69 | } 70 | 71 | s.queueURL = *resp.QueueUrl 72 | return err 73 | } 74 | 75 | func (s *sqsClient) deleteQueue(ctx context.Context) error { 76 | _, err := s.client.DeleteQueue(ctx, &sqs.DeleteQueueInput{ 77 | QueueUrl: aws.String(s.queueURL), 78 | }) 79 | if err != nil { 80 | log.Printf("sqs.DeleteQueue error: %s", err) 81 | return err 82 | } 83 | 84 | return err 85 | } 86 | 87 | func (s *sqsClient) pollQueue(ctx context.Context, signalChan chan os.Signal, prettyJSON bool) { 88 | log.Printf("polling queue %s ...", s.queueURL) 89 | log.Printf("press ctr+c to stop") 90 | defer close(signalChan) 91 | 92 | for { 93 | // goroutine 94 | select { 95 | case <-signalChan: 96 | log.Printf("stopping poller...") 97 | return 98 | 99 | default: 100 | resp, err := s.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ 101 | QueueUrl: aws.String(s.queueURL), 102 | MaxNumberOfMessages: 10, 103 | WaitTimeSeconds: 5, 104 | MessageAttributeNames: []string{"All"}, 105 | }) 106 | // handle recovery from 'dial tcp' errors 107 | if err != nil && strings.Contains(err.Error(), "dial tcp") { 108 | log.Printf("sqs.ReceiveMessage error: %s", err) 109 | 110 | // backoff 111 | time.Sleep(10 * time.Second) 112 | continue 113 | } 114 | // handle all other errors 115 | if err != nil { 116 | log.Printf("sqs.ReceiveMessage error: %s", err) 117 | return 118 | } 119 | 120 | if len(resp.Messages) == 0 { 121 | continue 122 | } 123 | 124 | entries := []types.DeleteMessageBatchRequestEntry{} 125 | for _, m := range resp.Messages { 126 | entries = append(entries, types.DeleteMessageBatchRequestEntry{ 127 | Id: m.MessageId, 128 | ReceiptHandle: m.ReceiptHandle, 129 | }) 130 | 131 | if prettyJSON { 132 | var j map[string]interface{} 133 | err := json.Unmarshal([]byte(*m.Body), &j) 134 | if err != nil { 135 | return 136 | } 137 | 138 | f := colorjson.NewFormatter() 139 | f.Indent = 2 140 | pj, _ := f.Marshal(j) 141 | 142 | log.Println(string(pj)) 143 | continue 144 | } 145 | 146 | log.Printf("%s", *m.Body) 147 | } 148 | 149 | // cleanup messages 150 | _, err = s.client.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{ 151 | QueueUrl: aws.String(s.queueURL), 152 | Entries: entries, 153 | }) 154 | if err != nil { 155 | log.Printf("sqs.DeleteMessageBatch error: %s", err) 156 | } 157 | } 158 | } 159 | } 160 | 161 | func (s *sqsClient) pollQueueCI(ctx context.Context, signalChan chan os.Signal, prettyJSON bool, timeout int64) { 162 | log.Printf("polling queue %s ...", s.queueURL) 163 | defer close(signalChan) 164 | 165 | for { 166 | // goroutine 167 | select { 168 | case <-signalChan: 169 | log.Printf("stopping poller...") 170 | return 171 | 172 | default: 173 | resp, err := s.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ 174 | QueueUrl: aws.String(s.queueURL), 175 | MaxNumberOfMessages: 10, 176 | WaitTimeSeconds: 5, 177 | MessageAttributeNames: []string{"All"}, 178 | }) 179 | if err != nil { 180 | log.Printf("sqs.ReceiveMessage error: %s", err) 181 | return 182 | } 183 | 184 | if len(resp.Messages) == 0 { 185 | continue 186 | } 187 | 188 | entries := []types.DeleteMessageBatchRequestEntry{} 189 | for _, m := range resp.Messages { 190 | entries = append(entries, types.DeleteMessageBatchRequestEntry{ 191 | Id: m.MessageId, 192 | ReceiptHandle: m.ReceiptHandle, 193 | }) 194 | 195 | if prettyJSON { 196 | var j map[string]interface{} 197 | err := json.Unmarshal([]byte(*m.Body), &j) 198 | if err != nil { 199 | return 200 | } 201 | 202 | f := colorjson.NewFormatter() 203 | f.Indent = 2 204 | pj, _ := f.Marshal(j) 205 | 206 | log.Printf("received event: %s", string(pj)) 207 | continue 208 | } 209 | 210 | log.Printf("received event: %s", *m.Body) 211 | } 212 | 213 | // cleanup messages 214 | _, err = s.client.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{ 215 | QueueUrl: aws.String(s.queueURL), 216 | Entries: entries, 217 | }) 218 | if err != nil { 219 | log.Printf("sqs.DeleteMessageBatch error: %s", err) 220 | } 221 | 222 | return 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /sqs_test.go: -------------------------------------------------------------------------------- 1 | // +build !integration 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "testing" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go-v2/aws" 15 | "github.com/aws/aws-sdk-go-v2/service/sqs" 16 | "github.com/aws/aws-sdk-go-v2/service/sqs/types" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | var _ sqsClientAPI = (*mockSQSclient)(nil) 21 | 22 | type mockSQSclient struct { 23 | err error 24 | 25 | queueURL *string 26 | receiveMessages []types.Message 27 | } 28 | 29 | const ( 30 | queueURL = "http://localhost" 31 | arn = "arn:aws:sqs:eu-north-1:1234567890:eventbridge-cli-14bc1c21-13ae-41a5-8951-76402ce2946e" 32 | ruleArn = "arn:aws:events:eu-north-1:1234567890:rule/eventbridge-cli-14bc1c21-13ae-41a5-8951-76402ce2946e" 33 | queueName = "eventbridge-cli-14bc1c21-13ae-41a5-8951-76402ce2946e" 34 | ) 35 | 36 | func init() { 37 | // disable logger 38 | log.SetOutput(ioutil.Discard) 39 | } 40 | 41 | func (m *mockSQSclient) CreateQueue(ctx context.Context, params *sqs.CreateQueueInput, optFns ...func(*sqs.Options)) (*sqs.CreateQueueOutput, error) { 42 | return &sqs.CreateQueueOutput{ 43 | QueueUrl: m.queueURL, 44 | }, m.err 45 | } 46 | 47 | func (m *mockSQSclient) DeleteQueue(ctx context.Context, params *sqs.DeleteQueueInput, optFns ...func(*sqs.Options)) (*sqs.DeleteQueueOutput, error) { 48 | return &sqs.DeleteQueueOutput{}, m.err 49 | } 50 | 51 | func (m *mockSQSclient) ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) { 52 | return &sqs.ReceiveMessageOutput{ 53 | Messages: m.receiveMessages, 54 | }, m.err 55 | } 56 | 57 | func (m *mockSQSclient) DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) { 58 | return &sqs.DeleteMessageBatchOutput{}, m.err 59 | } 60 | 61 | func Test_createQueue(t *testing.T) { 62 | tests := []struct { 63 | name string 64 | 65 | ruleArn string 66 | client *mockSQSclient 67 | want *sqsClient 68 | 69 | err bool 70 | }{ 71 | { 72 | name: "create SQS queue", 73 | ruleArn: ruleArn, 74 | client: &mockSQSclient{ 75 | queueURL: aws.String(queueURL), 76 | }, 77 | want: &sqsClient{ 78 | client: &mockSQSclient{ 79 | queueURL: aws.String(queueURL), 80 | }, 81 | arn: arn, 82 | queueName: queueName, 83 | queueURL: queueURL, 84 | }, 85 | err: false, 86 | }, 87 | { 88 | name: "create SQS queue error", 89 | ruleArn: ruleArn, 90 | client: &mockSQSclient{ 91 | err: errors.New("unable to create SQS queue"), 92 | }, 93 | err: true, 94 | }, 95 | } 96 | 97 | for _, test := range tests { 98 | t.Run(test.name, func(t *testing.T) { 99 | client := &sqsClient{ 100 | client: test.client, 101 | arn: arn, 102 | queueName: queueName, 103 | } 104 | 105 | err := client.createQueue(context.Background(), test.ruleArn) 106 | if test.err { 107 | assert.Error(t, err) 108 | return 109 | } 110 | 111 | assert.NoError(t, err) 112 | assert.Equal(t, test.want, client) 113 | }) 114 | } 115 | } 116 | 117 | func Test_deleteQueue(t *testing.T) { 118 | tests := []struct { 119 | name string 120 | client *mockSQSclient 121 | err bool 122 | }{ 123 | { 124 | name: "delete SQS queue", 125 | client: &mockSQSclient{}, 126 | err: false, 127 | }, 128 | { 129 | name: "delete SQS queue error", 130 | client: &mockSQSclient{ 131 | err: errors.New("unable to delete SQS queue"), 132 | }, 133 | err: true, 134 | }, 135 | } 136 | 137 | for _, test := range tests { 138 | t.Run(test.name, func(t *testing.T) { 139 | client := sqsClient{ 140 | client: test.client, 141 | queueURL: "https://localhost", 142 | arn: arn, 143 | } 144 | 145 | err := client.deleteQueue(context.Background()) 146 | if test.err { 147 | assert.Error(t, err) 148 | return 149 | } 150 | 151 | assert.NoError(t, err) 152 | }) 153 | } 154 | } 155 | 156 | func Test_pollQueueCancel(t *testing.T) { 157 | t.Run("stopping poller", func(t *testing.T) { 158 | client := sqsClient{} 159 | signalChan := make(chan os.Signal, 1) 160 | 161 | go client.pollQueue(context.Background(), signalChan, false) 162 | signalChan <- os.Interrupt 163 | }) 164 | } 165 | 166 | func Test_pollQueue(t *testing.T) { 167 | tests := []struct { 168 | name string 169 | 170 | client *mockSQSclient 171 | 172 | err bool 173 | }{ 174 | { 175 | name: "poll SQS queue", 176 | client: &mockSQSclient{ 177 | receiveMessages: []types.Message{ 178 | { 179 | MessageId: aws.String("dc909f9a-377b-cc13-627d-6fdbc2ea458c"), 180 | Body: aws.String(`{"detail-type":"Tag Change on Resource","source":"aws.tag"}`), 181 | }, 182 | }, 183 | }, 184 | err: false, 185 | }, 186 | { 187 | name: "poll SQS queue - no messages", 188 | client: &mockSQSclient{ 189 | receiveMessages: []types.Message{}, 190 | }, 191 | err: false, 192 | }, 193 | { 194 | name: "poll SQS queue - prettyJSON", 195 | client: &mockSQSclient{ 196 | receiveMessages: []types.Message{ 197 | { 198 | MessageId: aws.String("dc909f9a-377b-cc13-627d-6fdbc2ea458c"), 199 | Body: aws.String(`{"detail-type":"Tag Change on Resource","source":"aws.tag"}`), 200 | }, 201 | }, 202 | }, 203 | err: false, 204 | }, 205 | } 206 | 207 | for _, test := range tests { 208 | t.Run(test.name, func(t *testing.T) { 209 | signalChan := make(chan os.Signal, 1) 210 | client := &sqsClient{ 211 | client: test.client, 212 | queueURL: queueURL, 213 | } 214 | 215 | go client.pollQueue(context.Background(), signalChan, true) 216 | 217 | time.Sleep(2 * time.Second) 218 | signalChan <- os.Interrupt 219 | }) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /testdata/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0", 3 | "id": "cwe-test", 4 | "account": "123456789012", 5 | "region": "eu-north-1", 6 | "time": "2017-04-11T20:11:04Z", 7 | "source": [ 8 | "beta" 9 | ], 10 | "detail": { 11 | "channel": [ 12 | "web" 13 | ] 14 | }, 15 | "detail-type": [ 16 | "poc.succeeded" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /testdata/event_ci_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "alpha", 3 | "detail": "{\"channel\": \"web\"}", 4 | "detail-type": "poc.succeeded" 5 | } -------------------------------------------------------------------------------- /testdata/event_ci_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "beta", 3 | "detail": "{\"channel\": \"web\"}", 4 | "detail-type": "poc.succeeded" 5 | } 6 | -------------------------------------------------------------------------------- /testdata/eventpattern.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": [ 3 | "beta" 4 | ], 5 | "detail": { 6 | "channel": [ 7 | "web" 8 | ] 9 | }, 10 | "detail-type": [ 11 | "poc.succeeded" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /testdata/template.yaml: -------------------------------------------------------------------------------- 1 | Transform: AWS::Serverless-2016-10-31 2 | Description: EventBridge Beta Integration 3 | 4 | Resources: 5 | BetaFunction: 6 | Type: AWS::Serverless::Function 7 | Properties: 8 | FunctionName: eventbridge-beta 9 | Description: "EventBridge - Beta (β)" 10 | Handler: beta 11 | Runtime: go1.x 12 | Timeout: 6 13 | CodeUri: ./source/ 14 | Tracing: Active 15 | Events: 16 | EventBridge: 17 | Type: EventBridgeRule 18 | Properties: 19 | EventBusName: default 20 | Pattern: 21 | source: 22 | - beta 23 | detail: 24 | channel: 25 | - web 26 | # also valid 27 | # Pattern: { "source": [ "beta" ], "detail": { "channel": [ "web" ] } } 28 | --------------------------------------------------------------------------------