├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── example ├── example.go ├── service │ └── main.go └── tester │ └── main.go ├── go.mod ├── go.sum ├── orbital ├── orbital.go ├── orbital_test.go └── service.go └── webhook ├── routelogger.go ├── routelogger_test.go ├── subscriber.go ├── types.go └── webhook.go /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | go: 10 | - '1.13.4' 11 | - '1.17.x' 12 | - '1.18.x' 13 | - '1.19.x' 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Setup Go ${{ matrix.go }} 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: ${{ matrix.go }} 23 | 24 | - name: Run Tests 25 | run: make test 26 | 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverprofile 2 | vendor 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2018 Segment 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell git describe --tags --always --dirty="-dev") 2 | LDFLAGS := -ldflags='-X "main.version=$(VERSION)"' 3 | Q=@ 4 | 5 | GOTESTFLAGS = -race 6 | ifndef Q 7 | GOTESTFLAGS += -v 8 | endif 9 | 10 | .PHONY: clean 11 | clean: 12 | $Qrm -rf vendor/ && git checkout ./vendor && dep ensure 13 | 14 | .PHONY: vet 15 | vet: 16 | $Qgo vet ./... 17 | 18 | .PHONY: fmtcheck 19 | fmtchk: 20 | $Qexit $(shell goimports -l . | grep -v '^vendor' | wc -l) 21 | 22 | .PHONY: fmtfix 23 | fmtfix: 24 | $Qgoimports -w $(shell find . -iname '*.go' | grep -v vendor) 25 | 26 | .PHONY: test 27 | test: vet fmtcheck 28 | $Qgo test $(GOTESTFLAGS) -coverpkg="./..." -coverprofile=.coverprofile ./... 29 | $Qgo tool cover -func=.coverprofile 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | orbital 2 | ------- 3 | 4 | [![Build](https://circleci.com/gh/segmentio/orbital.svg?style=shield&circle-token=d06625b898a4090cd613386530e9296a286b6c2b)](https://circleci.com/gh/segmentio/orbital) 5 | [![GoDoc](https://godoc.org/github.com/segmentio/orbital?status.svg)](https://godoc.org/github.com/segmentio/orbital) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/segmentio/orbital#)](https://goreportcard.com/report/github.com/segmentio/orbital) 7 | [![License](https://img.shields.io/badge/license-MIT-5B74AD.svg)](https://github.com/segmentio/orbital/blob/master/LICENSE) 8 | 9 | Orbital is a test framework which enables a developer to write end to end tests 10 | just like one would writing unit tests. We do this by effectively copying the 11 | `testing.T` API and registering tests to be run periodically on a configured 12 | schedule. 13 | 14 | This package is not yet API stable. Use with the understanding that it might 15 | change as time goes on. 16 | 17 | ### motivation 18 | 19 | Writing tests should be easy. This includes oft-neglected end-to-end tests 20 | which provide arguably the most value. End to end tests can be used for 21 | functional verification before a release, alerts when your site isn't behaving 22 | correctly, or simply just providing metrics about your site or service. 23 | 24 | ### usage 25 | 26 | The goal is to make writing end-to-end tests simple and to take the effort out 27 | of building these systems. To enable that, a number of packages are provided 28 | to aid in this effort. The webhook package provides a simple way to receive 29 | notifications of received events. With those packages together, we can write 30 | elegant tests like the following. 31 | 32 | 33 | ```go 34 | type Harness struct { 35 | RouteLogger *webhook.RouteLogger 36 | } 37 | 38 | func (h *Harness) OrbitalSmoke(ctx context.Context, o *orbital.O) { 39 | s := sender{APIKey: "super-private-api-key"} 40 | // Send request to API for handling 41 | id := s.send([]byte(tmpl)) 42 | 43 | // tell the webhook we're waiting to receive this message 44 | err := h.RouteLogger.Sent(id) 45 | if err != nil { 46 | o.Errorf("%{error}v", err) 47 | return 48 | } 49 | // Cleanup 50 | defer h.RouteLogger.Delete(id) 51 | 52 | // wait for this message to be received by the webhook 53 | _, err = h.RouteLogger.Wait(ctx, id) 54 | if err != nil { 55 | o.Errorf("%{error}v", err) 56 | return 57 | } 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /example/example.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/segmentio/orbital/orbital" 12 | "github.com/segmentio/orbital/webhook" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/yields/phony/pkg/phony" 15 | ) 16 | 17 | type Harness struct { 18 | API string 19 | Waiter *webhook.RouteLogger 20 | } 21 | 22 | type event struct { 23 | Email string `json:"email"` 24 | Timestamp time.Time `json:"timestamp"` 25 | ID string `json:"id"` 26 | Processed bool `json:"processed"` 27 | } 28 | 29 | func (h Harness) OrbitalSmoke(ctx context.Context, o *orbital.O) { 30 | evt := event{ 31 | Email: phony.Get("email"), 32 | ID: phony.Get("ksuid"), 33 | Timestamp: time.Now().UTC(), 34 | } 35 | // Mark the event as Sent. 36 | err := h.Waiter.Sent(evt.ID) 37 | assert.NoError(o, err, "error marking sent") 38 | // Cleanup after we're done. 39 | defer h.Waiter.Delete(evt.ID) 40 | assert.NoError(o, send(h.API, evt), "sending event shouldn't fail") 41 | // Block until the event has been received 42 | r, err := h.Waiter.Wait(ctx, evt.ID) 43 | assert.NoError(o, err, "error waiting") 44 | var recv event 45 | err = json.Unmarshal([]byte(r.Body), &recv) 46 | assert.NoError(o, err, "error unmarshaling") 47 | assert.True(o, recv.Processed, "processed should be set to true") 48 | } 49 | 50 | func send(api string, e event) error { 51 | bs := bytes.NewBuffer(nil) 52 | enc := json.NewEncoder(bs) 53 | err := enc.Encode(e) 54 | if err != nil { 55 | return err 56 | } 57 | resp, err := http.Post(api, "application/json", bs) 58 | if err != nil { 59 | return err 60 | } 61 | defer resp.Body.Close() 62 | if resp.StatusCode != 200 { 63 | return errors.New("non-200 status") 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /example/service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httputil" 9 | _ "net/http/pprof" 10 | "os" 11 | "os/signal" 12 | "path" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/justinas/alice" 17 | "github.com/segmentio/conf" 18 | "github.com/tidwall/sjson" 19 | 20 | "github.com/segmentio/events" 21 | _ "github.com/segmentio/events/ecslogs" 22 | "github.com/segmentio/events/httpevents" 23 | _ "github.com/segmentio/events/log" 24 | _ "github.com/segmentio/events/sigevents" 25 | _ "github.com/segmentio/events/text" 26 | 27 | "github.com/segmentio/stats" 28 | "github.com/segmentio/stats/httpstats" 29 | "github.com/segmentio/stats/prometheus" 30 | ) 31 | 32 | var ( 33 | version = "dev" 34 | config = cfg{ 35 | Address: ":3000", 36 | Upstream: "localhost:3001", 37 | } 38 | prog string = path.Base(os.Args[0]) 39 | ) 40 | 41 | func main() { 42 | events.Log("%{program}s version: %{version}s", prog, version) 43 | conf.Load(&config) 44 | events.Log("service starting with config: %+{config}v", config) 45 | initLogMetrics() 46 | 47 | // Copied from defaultDirector in httputil, with modification 48 | forward := &httputil.ReverseProxy{ 49 | Director: func(req *http.Request) { 50 | req.URL.Scheme = "http" 51 | req.URL.Host = config.Upstream 52 | 53 | bs, err := ioutil.ReadAll(req.Body) 54 | if err != nil { 55 | panic(err) 56 | } 57 | ss, err := sjson.Set(string(bs), "processed", true) 58 | if err != nil { 59 | panic(err) 60 | } 61 | req.ContentLength = int64(len(ss)) 62 | req.Body = ioutil.NopCloser(bytes.NewBufferString(ss)) 63 | }, 64 | } 65 | 66 | mux := http.NewServeMux() 67 | chain := alice.New(httpstats.NewHandler, httpevents.NewHandler) 68 | mux.HandleFunc("/internal/health", health) 69 | mux.Handle("/", chain.Then(forward)) 70 | 71 | server := &http.Server{Addr: config.Address, Handler: mux} 72 | errc := make(chan error) 73 | go func() { 74 | errc <- server.ListenAndServe() 75 | }() 76 | 77 | sigchan := make(chan os.Signal, 1) 78 | signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM) 79 | 80 | select { 81 | case sig := <-sigchan: 82 | events.Log("stopping in response to signal %{signal}s.", sig) 83 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 84 | defer cancel() // <- govet 85 | server.Shutdown(ctx) 86 | case err := <-errc: 87 | events.Log("stopping in error %+{error}v.", err) 88 | } 89 | } 90 | 91 | // health implements an ELB health check and responds with an HTTP 200. 92 | func health(w http.ResponseWriter, r *http.Request) { 93 | w.Header().Set("Content-Type", "text/plain") 94 | w.Header().Set("Content-Length", "0") 95 | w.WriteHeader(200) 96 | } 97 | 98 | type cfg struct { 99 | Address string `conf:"address" help:"address on which the server should listen"` 100 | Upstream string `conf:"upstream" help:"proxy upstream addr"` 101 | } 102 | 103 | func initLogMetrics() { 104 | events.DefaultLogger.Args = events.Args{events.Arg{Name: "version", Value: version}} 105 | events.DefaultLogger.EnableDebug = false 106 | stats.DefaultEngine = stats.NewEngine(prog, prometheus.DefaultHandler, 107 | stats.T("version", version), 108 | ) 109 | // Force a metrics flush every second 110 | go func() { 111 | for range time.Tick(time.Second) { 112 | stats.Flush() 113 | } 114 | }() 115 | } 116 | -------------------------------------------------------------------------------- /example/tester/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | _ "net/http/pprof" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/justinas/alice" 14 | "github.com/segmentio/conf" 15 | "github.com/segmentio/orbital/example" 16 | "github.com/segmentio/orbital/orbital" 17 | "github.com/segmentio/orbital/webhook" 18 | "github.com/tidwall/gjson" 19 | 20 | "github.com/segmentio/events" 21 | _ "github.com/segmentio/events/ecslogs" 22 | "github.com/segmentio/events/httpevents" 23 | _ "github.com/segmentio/events/log" 24 | _ "github.com/segmentio/events/sigevents" 25 | _ "github.com/segmentio/events/text" 26 | 27 | "github.com/segmentio/stats" 28 | "github.com/segmentio/stats/httpstats" 29 | "github.com/segmentio/stats/prometheus" 30 | ) 31 | 32 | var ( 33 | version = "dev" 34 | config = cfg{ 35 | Address: ":3001", 36 | } 37 | prog string = path.Base(os.Args[0]) 38 | ) 39 | 40 | func main() { 41 | events.Log("%{program}s version: %{version}s", prog, version) 42 | conf.Load(&config) 43 | events.Log("service starting with config: %+{config}v", config) 44 | initLogMetrics() 45 | 46 | // Configure end-to-end tests 47 | orb := orbital.New( 48 | orbital.WithStats(stats.DefaultEngine), 49 | orbital.WithTimeout(5*time.Second), 50 | ) 51 | // Manages hooking events back into the test. The tests must know to wait 52 | // on this ID 53 | rl := webhook.NewRouteLogger(func(r webhook.Request) string { 54 | return gjson.Get(r.Body, "id").Str 55 | }) 56 | // receives the events and logs them 57 | wh := webhook.New(webhook.Config{ 58 | Logger: rl, 59 | }) 60 | // Configuration for the test. 61 | eh := example.Harness{ 62 | API: "http://localhost:3000/", 63 | Waiter: rl, 64 | } 65 | orb.Register(orbital.TestCase{ 66 | Name: "smoke test", 67 | Period: 1 * time.Second, 68 | Timeout: 3 * time.Second, 69 | Func: eh.OrbitalSmoke, 70 | }) 71 | orb.Run() 72 | 73 | mux := http.NewServeMux() 74 | chain := alice.New(httpstats.NewHandler, httpevents.NewHandler) 75 | mux.HandleFunc("/internal/health", health) 76 | mux.Handle("/internal/metrics", prometheus.DefaultHandler) 77 | mux.Handle("/", chain.Then(wh)) 78 | 79 | server := &http.Server{Addr: config.Address, Handler: mux} 80 | errc := make(chan error) 81 | go func() { 82 | errc <- server.ListenAndServe() 83 | }() 84 | 85 | sigchan := make(chan os.Signal, 1) 86 | signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM) 87 | 88 | select { 89 | case sig := <-sigchan: 90 | events.Log("stopping in response to signal %{signal}s.", sig) 91 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 92 | defer cancel() // <- govet 93 | server.Shutdown(ctx) 94 | case err := <-errc: 95 | events.Log("stopping in error %+{error}v.", err) 96 | } 97 | orb.Close() 98 | } 99 | 100 | // health implements an ELB health check and responds with an HTTP 200. 101 | func health(w http.ResponseWriter, r *http.Request) { 102 | w.Header().Set("Content-Type", "text/plain") 103 | w.Header().Set("Content-Length", "0") 104 | w.WriteHeader(200) 105 | } 106 | 107 | type cfg struct { 108 | Address string `conf:"address" help:"address on which the server should listen"` 109 | } 110 | 111 | func initLogMetrics() { 112 | events.DefaultLogger.Args = events.Args{events.Arg{Name: "version", Value: version}} 113 | events.DefaultLogger.EnableDebug = false 114 | stats.DefaultEngine = stats.NewEngine(prog, prometheus.DefaultHandler, 115 | stats.T("version", version), 116 | ) 117 | // Force a metrics flush every second 118 | go func() { 119 | for range time.Tick(time.Second) { 120 | stats.Flush() 121 | } 122 | }() 123 | } 124 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/segmentio/orbital 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 7 | github.com/google/uuid v1.0.0 8 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da 9 | github.com/pkg/errors v0.8.0 10 | github.com/pmezard/go-difflib v1.0.0 11 | github.com/segmentio/conf v1.0.0 12 | github.com/segmentio/events v2.0.1+incompatible 13 | github.com/segmentio/fasthash v1.0.0 14 | github.com/segmentio/go-snakecase v1.0.0 15 | github.com/segmentio/ksuid v1.0.1 16 | github.com/segmentio/objconv v0.0.0-20180216231955-8998c9cea5fb 17 | github.com/segmentio/stats v3.0.1-0.20180216230223-8a8e19fb47d6+incompatible 18 | github.com/stretchr/testify v1.2.2 19 | github.com/tidwall/gjson v1.1.3 20 | github.com/tidwall/match v1.0.0 21 | github.com/tidwall/sjson v1.0.4 22 | github.com/yields/phony v0.0.0-20170811225840-ce3b0822889c 23 | gopkg.in/go-playground/mold.v2 v2.2.0 24 | gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 25 | gopkg.in/yaml.v2 v2.2.1 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= 4 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A= 6 | github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8= 7 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 8 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/segmentio/conf v1.0.0 h1:oRF4BtoJbI/+I7fUngYMnMcKFbjqVUFi8hv4Pp0l88w= 12 | github.com/segmentio/conf v1.0.0/go.mod h1:y0VyxYAlU2slxCjm7XX7tGKFlN39bwHCZrbOpCcLsr8= 13 | github.com/segmentio/events v2.0.1+incompatible h1:5Cdtdv9FX2XWmf+CQVWyEuAJ+pEi4Ec8AkT8wCYurfQ= 14 | github.com/segmentio/events v2.0.1+incompatible/go.mod h1:npQUbmKYO33tlRpaQNZjgD2mXv0fb2hbOH0CNVs6g2Y= 15 | github.com/segmentio/fasthash v1.0.0 h1:7D0T9cPBdXpSUIH+wa8E6PuiccPrg5UGnCGSeQSR7cQ= 16 | github.com/segmentio/fasthash v1.0.0/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= 17 | github.com/segmentio/go-snakecase v1.0.0 h1:FSeHpP0sBL3O+MCpxvQZrS5a51WAki6gposZuwVE9L4= 18 | github.com/segmentio/go-snakecase v1.0.0/go.mod h1:jk1miR5MS7Na32PZUykG89Arm+1BUSYhuGR6b7+hJto= 19 | github.com/segmentio/ksuid v1.0.1 h1:O/0HN9qcXwqemHNVUT0L24al4IQLjwOFw5mWUy5wunE= 20 | github.com/segmentio/ksuid v1.0.1/go.mod h1:BXuJDr2byAiHuQaQtSKoXh1J0YmUDurywOXgB2w+OSU= 21 | github.com/segmentio/objconv v0.0.0-20180216231955-8998c9cea5fb h1:nZWLujBLSZl17oWF9/adTx/fuBwUtrQRBDX9WJFrR6g= 22 | github.com/segmentio/objconv v0.0.0-20180216231955-8998c9cea5fb/go.mod h1:l6IU9rGW/wpM+MUyfJLDCoJ+XXAv6wbpkqej46ESQ6g= 23 | github.com/segmentio/stats v3.0.1-0.20180216230223-8a8e19fb47d6+incompatible h1:v3FDsUyKuE7sB0zjveguOw65n1b6YkEKzAf/oyk3aYQ= 24 | github.com/segmentio/stats v3.0.1-0.20180216230223-8a8e19fb47d6+incompatible/go.mod h1:ZkGKMkt6GVRIsV5Biy4HotVqonMWEsr+uMtOD2NBDeU= 25 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 26 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 27 | github.com/tidwall/gjson v1.1.3 h1:u4mspaByxY+Qk4U1QYYVzGFI8qxN/3jtEV0ZDb2vRic= 28 | github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA= 29 | github.com/tidwall/match v1.0.0 h1:Ym1EcFkp+UQ4ptxfWlW+iMdq5cPH5nEuGzdf/Pb7VmI= 30 | github.com/tidwall/match v1.0.0/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= 31 | github.com/tidwall/sjson v1.0.4 h1:UcdIRXff12Lpnu3OLtZvnc03g4vH2suXDXhBwBqmzYg= 32 | github.com/tidwall/sjson v1.0.4/go.mod h1:bURseu1nuBkFpIES5cz6zBtjmYeOQmEESshn7VpF15Y= 33 | github.com/yields/phony v0.0.0-20170811225840-ce3b0822889c h1:4cDAvXqtqaPgYmzbTn4JbDBNvjkaq91dXZOoCsWjirc= 34 | github.com/yields/phony v0.0.0-20170811225840-ce3b0822889c/go.mod h1:7opDrjWW7ZEsx5nmIX24hnQmw7ClbNGuK4zmDeQNIEU= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/go-playground/mold.v2 v2.2.0 h1:Y4IYB4/HYQfuq43zaKh6vs9cVelLE9qbqe2fkyfCTWQ= 37 | gopkg.in/go-playground/mold.v2 v2.2.0/go.mod h1:XMyyRsGtakkDPbxXbrA5VODo6bUXyvoDjLd5l3T0XoA= 38 | gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc= 39 | gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= 40 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 41 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 42 | -------------------------------------------------------------------------------- /orbital/orbital.go: -------------------------------------------------------------------------------- 1 | package orbital 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "sync" 8 | "time" 9 | 10 | "github.com/segmentio/stats" 11 | ) 12 | 13 | // TestCase represents an individual test to be run on a schedule given by 14 | // Period. If Timeout is not specified, Service will provide a default timeout. 15 | // Name should be metrics-compatible, for now. 16 | type TestCase struct { 17 | Period time.Duration 18 | Name string 19 | Func TestFunc 20 | Timeout time.Duration 21 | Tags []stats.Tag 22 | } 23 | 24 | // TestFunc represents a function to be run under test 25 | // Should block until complete 26 | // Responsible for cleaning up any allocated resources 27 | // or spawned goroutines before returning 28 | // Should select on ctx.Done for long running operations 29 | type TestFunc func(ctx context.Context, o *O) 30 | 31 | // O is the base construct for orbital. It should be used for logging, 32 | // metrics access and most importantly, signaling if a test has failed. 33 | type O struct { 34 | // output writer 35 | w io.Writer 36 | 37 | stats *stats.Engine 38 | failed bool 39 | mu sync.Mutex 40 | } 41 | 42 | // Error is equivalent to Log followed by Fail 43 | func (o *O) Error(args ...interface{}) { 44 | o.log(fmt.Sprintln(args...)) 45 | o.Fail() 46 | } 47 | 48 | // Errorf is equivalent to Logf followed by Fail 49 | func (o *O) Errorf(fstr string, args ...interface{}) { 50 | o.log(fmt.Sprintf(fstr, args...)) 51 | o.Fail() 52 | } 53 | 54 | // Fatal functions exist in testing.T because they call 55 | // runtime.Goexit to prevent having to deal with context 56 | // and because it's convenient. 57 | // Not sure they're worth having in an always running 58 | // daemon 59 | // func (o *O) Fatal(args ...interface{}) { 60 | // o.log(fmt.Sprintln(args...)) 61 | // } 62 | // 63 | // func (o *O) Fatalf(fstr string, args ...interface{}) { 64 | // o.log(fmt.Sprintf(fstr, args...)) 65 | // } 66 | 67 | func (o *O) Log(args ...interface{}) { 68 | o.log(fmt.Sprintln(args...)) 69 | } 70 | 71 | func (o *O) Logf(fstr string, args ...interface{}) { 72 | o.log(fmt.Sprintf(fstr, args...)) 73 | } 74 | 75 | func (o *O) log(s string) { 76 | if len(s) == 0 || s[len(s)-1] != '\n' { 77 | s += "\n" 78 | } 79 | 80 | fmt.Fprint(o.w, s) 81 | } 82 | 83 | func (o *O) Fail() { 84 | o.mu.Lock() 85 | defer o.mu.Unlock() 86 | o.failed = true 87 | } 88 | 89 | func (o *O) Stats() *stats.Engine { 90 | return o.stats 91 | } 92 | -------------------------------------------------------------------------------- /orbital/orbital_test.go: -------------------------------------------------------------------------------- 1 | package orbital 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func init() { 10 | Register(TestCase{ 11 | Period: 400 * time.Microsecond, 12 | Func: func(ctx context.Context, o *O) { 13 | time.Sleep(550 * time.Microsecond) 14 | o.Log("in test case") 15 | }, 16 | Name: "smoke test", 17 | }) 18 | 19 | Register(TestCase{ 20 | Period: 300 * time.Microsecond, 21 | Func: func(ctx context.Context, o *O) { 22 | time.Sleep(500 * time.Microsecond) 23 | o.Log("in test case") 24 | }, 25 | Name: "secondary test", 26 | }) 27 | 28 | } 29 | 30 | func TestOrbital(t *testing.T) { 31 | DefaultService.Run() 32 | time.Sleep(2 * time.Millisecond) 33 | DefaultService.Close() 34 | } 35 | -------------------------------------------------------------------------------- /orbital/service.go: -------------------------------------------------------------------------------- 1 | package orbital 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "github.com/segmentio/stats" 12 | ) 13 | 14 | // Service runs all registered TestCases on the schedule specified during 15 | // registration. 16 | type Service struct { 17 | // list of tests to run 18 | tests []TestCase 19 | stats *stats.Engine 20 | mu sync.Mutex 21 | started bool 22 | 23 | defaultTimeout time.Duration 24 | 25 | w io.Writer 26 | 27 | done chan struct{} 28 | once sync.Once 29 | wg sync.WaitGroup 30 | } 31 | 32 | func WithStats(s *stats.Engine) func(*Service) { 33 | return func(svc *Service) { 34 | svc.stats = s 35 | } 36 | } 37 | 38 | func WithTimeout(d time.Duration) func(*Service) { 39 | return func(svc *Service) { 40 | svc.defaultTimeout = d 41 | } 42 | } 43 | 44 | func New(opts ...func(*Service)) *Service { 45 | s := &Service{ 46 | w: os.Stderr, 47 | done: make(chan struct{}), 48 | tests: make([]TestCase, 0), 49 | defaultTimeout: 10 * time.Minute, 50 | } 51 | for _, o := range opts { 52 | o(s) 53 | } 54 | return s 55 | } 56 | 57 | var DefaultService = New() 58 | 59 | func Register(tc TestCase) { 60 | DefaultService.Register(tc) 61 | } 62 | 63 | // Register a test case to be run. 64 | func (s *Service) Register(tc TestCase) { 65 | s.mu.Lock() 66 | defer s.mu.Unlock() 67 | s.tests = append(s.tests, tc) 68 | } 69 | 70 | func (s *Service) Run() { 71 | s.mu.Lock() 72 | defer s.mu.Unlock() 73 | if s.stats == nil { 74 | s.stats = stats.DefaultEngine 75 | } 76 | if !s.started { 77 | for _, v := range s.tests { 78 | s.wg.Add(1) 79 | go s.run(v) 80 | } 81 | } 82 | s.started = true 83 | } 84 | 85 | func (s *Service) handle(ctx context.Context, tc TestCase) { 86 | start := time.Now() 87 | o := &O{ 88 | w: s.w, 89 | stats: s.stats, 90 | } 91 | to := s.defaultTimeout 92 | if tc.Timeout > 10*time.Millisecond { 93 | to = tc.Timeout 94 | } 95 | c, cancel := context.WithTimeout(ctx, to) 96 | defer cancel() 97 | tc.Func(c, o) 98 | if c.Err() != nil && !o.failed { 99 | o.Errorf("failed on context error: %v", c.Err()) 100 | } 101 | dur := time.Now().Sub(start) 102 | if o.failed { 103 | tags := append([]stats.Tag{ 104 | stats.T("case", tc.Name), 105 | stats.T("result", "fail"), 106 | }, tc.Tags...) 107 | s.stats.Observe("case", dur, tags...) 108 | fmt.Fprintf(s.w, "--- FAIL: %s (%s)\n", tc.Name, dur) 109 | } else { 110 | tags := append([]stats.Tag{ 111 | stats.T("case", tc.Name), 112 | stats.T("result", "pass"), 113 | }, tc.Tags...) 114 | s.stats.Observe("case", dur, tags...) 115 | fmt.Fprintf(s.w, "--- PASS: %s (%s)\n", tc.Name, dur) 116 | } 117 | } 118 | 119 | func (s *Service) run(tc TestCase) { 120 | tick := time.NewTicker(tc.Period) 121 | // Waitgroup for different invocations of this test case 122 | var wg sync.WaitGroup 123 | 124 | loop: 125 | for { 126 | select { 127 | case <-tick.C: 128 | case <-s.done: 129 | tick.Stop() 130 | break loop 131 | } 132 | ctx, cancel := context.WithCancel(context.Background()) 133 | complete := make(chan struct{}) 134 | wg.Add(1) 135 | go func(c context.Context) { 136 | s.handle(c, tc) 137 | close(complete) 138 | wg.Done() 139 | }(ctx) 140 | // Cancel the above goroutine on shutdown without blocking the loop 141 | go func(comp chan struct{}, c context.CancelFunc) { 142 | select { 143 | case <-s.done: 144 | // TODO: make this configurable at the service level. i.e. 145 | // provide the option to allow the inflight tests to finish 146 | // without failing 147 | c() 148 | case <-comp: 149 | } 150 | }(complete, cancel) 151 | } 152 | wg.Wait() 153 | s.wg.Done() 154 | } 155 | 156 | func (s *Service) Close() error { 157 | s.once.Do(func() { 158 | close(s.done) 159 | }) 160 | s.wg.Wait() 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /webhook/routelogger.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "sync" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/segmentio/events" 10 | ) 11 | 12 | // RouteLogger is a struct to enable being notified when an request identified 13 | // by a key has been received by the It satisfies the Logger 14 | // interface 15 | type RouteLogger struct { 16 | key func(Request) string 17 | 18 | rc map[string]chan Request 19 | mu sync.Mutex 20 | } 21 | 22 | // NewRouteLogger takes a key function which can generate a string key based 23 | // upon the given Request. This key will be used to notify goroutines blocked 24 | // on RouteLogger.Wait. 25 | func NewRouteLogger(key func(Request) string) *RouteLogger { 26 | return &RouteLogger{ 27 | rc: make(map[string]chan Request), 28 | key: key, 29 | } 30 | } 31 | 32 | func (s *RouteLogger) Record(r Request) { 33 | events.Debug("%+v", r) 34 | s.mu.Lock() 35 | defer s.mu.Unlock() 36 | k := s.key(r) 37 | c, ok := s.rc[k] 38 | 39 | if !ok { 40 | return 41 | } 42 | 43 | // Requests are dropped if writing chan would block 44 | select { 45 | case c <- r: 46 | default: 47 | events.Debug("chan full, dropping request") 48 | } 49 | } 50 | 51 | // Sent prepares RouteLogger to be able to handle the incoming request Sent 52 | // returns an error if the key already exists in the map. Keys must be unique 53 | func (s *RouteLogger) Sent(key string) error { 54 | s.mu.Lock() 55 | defer s.mu.Unlock() 56 | if _, ok := s.rc[key]; ok { 57 | return errors.Errorf("key %s already sent", key) 58 | } 59 | // Channel buffer 1 prevents the race between receiving an event on the 60 | // webhook and calling `Wait`. Otherwise, the event would be dropped in 61 | // the select statement for Record 62 | s.rc[key] = make(chan Request, 1) 63 | return nil 64 | } 65 | 66 | // Wait blocks until it's received a message on the webhook matching the key, 67 | // or ctx.Done() is triggered. If Wait is called before Sent, an error is 68 | // returned. 69 | func (s *RouteLogger) Wait(ctx context.Context, key string) (Request, error) { 70 | s.mu.Lock() 71 | c, ok := s.rc[key] 72 | if !ok { 73 | return Request{}, errors.New("Wait called before Sent") 74 | } 75 | s.mu.Unlock() 76 | select { 77 | case r := <-c: 78 | return r, nil 79 | case <-ctx.Done(): 80 | return Request{}, ctx.Err() 81 | } 82 | } 83 | 84 | // WaitN waits for n events within the given keys. 85 | // If WaitFor is called before one the the given keys has been sent, an error 86 | // is returned. 87 | func (s *RouteLogger) WaitN(ctx context.Context, n int, keys ...string) ([]Request, error) { 88 | requests := make([]Request, 0, len(keys)) 89 | 90 | cases := []reflect.SelectCase{ 91 | { 92 | Dir: reflect.SelectRecv, 93 | Chan: reflect.ValueOf(ctx.Done()), 94 | }, 95 | } 96 | 97 | s.mu.Lock() 98 | for _, key := range keys { 99 | c, ok := s.rc[key] 100 | if !ok { 101 | return nil, errors.Errorf("WaitAll called before key %s was sent", key) 102 | } 103 | 104 | cases = append(cases, reflect.SelectCase{ 105 | Dir: reflect.SelectRecv, 106 | Chan: reflect.ValueOf(c), 107 | }) 108 | } 109 | s.mu.Unlock() 110 | 111 | for n > 0 { 112 | chosen, value, ok := reflect.Select(cases) 113 | 114 | switch chosen { 115 | case 0: 116 | // ctx.Done(). 117 | return nil, ctx.Err() 118 | 119 | default: 120 | // The chan is closed so we stop to listen on this channel. 121 | if !ok { 122 | cases[chosen].Chan = reflect.ValueOf(nil) 123 | continue 124 | } 125 | 126 | req := Request{} 127 | if req, ok = value.Interface().(Request); !ok { 128 | return nil, errors.Errorf("WaitAll did not get a Request: %T", value.Type()) 129 | } 130 | 131 | requests = append(requests, req) 132 | n-- 133 | } 134 | } 135 | return requests, nil 136 | } 137 | 138 | // Delete must be called after any successful call to Sent to free allocated 139 | // resources. Not doing so results in a leak. 140 | func (s *RouteLogger) Delete(key string) { 141 | s.mu.Lock() 142 | delete(s.rc, key) 143 | s.mu.Unlock() 144 | } 145 | -------------------------------------------------------------------------------- /webhook/routelogger_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRouteLogger(t *testing.T) { 13 | rl := NewRouteLogger(bodyMatch) 14 | 15 | rl.Sent("blah") 16 | 17 | wr := Request{ 18 | Method: "FOO", 19 | Body: "blah", 20 | } 21 | rl.Record(wr) 22 | 23 | x, err := rl.Wait(context.Background(), "blah") 24 | assert.Equal(t, wr, x, "received request should equal sent") 25 | assert.NoError(t, err) 26 | } 27 | 28 | func TestRouteLoggerWaitFor(t *testing.T) { 29 | tests := []struct { 30 | scenario string 31 | in []string 32 | wait []string 33 | err bool 34 | timeoutErr bool 35 | }{ 36 | { 37 | scenario: "wait one", 38 | in: []string{"hello"}, 39 | wait: []string{"hello"}, 40 | }, 41 | { 42 | scenario: "wait Many", 43 | in: []string{ 44 | "hello", 45 | "world", 46 | "orbital", 47 | "roxx", 48 | }, 49 | wait: []string{ 50 | "hello", 51 | "world", 52 | "orbital", 53 | "roxx", 54 | }, 55 | }, 56 | { 57 | scenario: "wait not expected", 58 | in: []string{ 59 | "hello", 60 | "world", 61 | "orbital", 62 | "roxx", 63 | }, 64 | wait: []string{ 65 | "orbital", 66 | "roxx", 67 | "boo", 68 | }, 69 | err: true, 70 | }, 71 | { 72 | scenario: "wait timeout", 73 | in: []string{ 74 | "hello", 75 | }, 76 | wait: []string{ 77 | "hello", 78 | "orbital", 79 | }, 80 | timeoutErr: true, 81 | }, 82 | } 83 | 84 | for _, test := range tests { 85 | t.Run(test.scenario, func(t *testing.T) { 86 | rl := NewRouteLogger(bodyMatch) 87 | 88 | for _, in := range test.in { 89 | err := rl.Sent(in) 90 | require.NoError(t, err) 91 | 92 | rl.Record(Request{ 93 | Method: "Test", 94 | Body: in, 95 | }) 96 | } 97 | 98 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 99 | defer cancel() 100 | 101 | requests, err := rl.WaitN(ctx, len(test.wait), test.in...) 102 | if test.timeoutErr { 103 | require.Error(t, err) 104 | return 105 | } 106 | 107 | require.NoError(t, err) 108 | require.Equal(t, len(test.wait), len(requests)) 109 | 110 | out := make([]string, len(test.wait)) 111 | for i, r := range requests { 112 | out[i] = r.Body 113 | } 114 | 115 | if test.err { 116 | require.NotSubset(t, out, test.wait) 117 | return 118 | } 119 | require.Subset(t, out, test.wait) 120 | 121 | }) 122 | } 123 | } 124 | 125 | func bodyMatch(wr Request) string { 126 | return wr.Body 127 | } 128 | -------------------------------------------------------------------------------- /webhook/subscriber.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // Subscriber is a subscriber that listen events from a webhook set 9 | // at the end of the pipeline. 10 | type Subscriber struct { 11 | RouteLogger *RouteLogger 12 | keys map[string]struct{} 13 | } 14 | 15 | // Subscribe subscribes to the given keys. 16 | // It satisfies the e2e.Subscriber interface. 17 | // Keys should be an event messageId. 18 | func (s *Subscriber) Subscribe(keys ...string) error { 19 | var err error 20 | defer func() { 21 | if err != nil { 22 | s.Unsubscribe(keys...) 23 | } 24 | }() 25 | 26 | if s.keys == nil { 27 | s.keys = make(map[string]struct{}) 28 | } 29 | 30 | for _, key := range keys { 31 | if err = s.RouteLogger.Sent(key); err != nil { 32 | return err 33 | } 34 | s.keys[key] = struct{}{} 35 | } 36 | return nil 37 | } 38 | 39 | // Unsubscribe satisfies the e2e.Subscriber interface. 40 | func (s *Subscriber) Unsubscribe(keys ...string) { 41 | for _, key := range keys { 42 | delete(s.keys, key) 43 | s.RouteLogger.Delete(key) 44 | } 45 | } 46 | 47 | // WaitN satisfies the e2e.Subscriber interface. 48 | func (s *Subscriber) WaitN(ctx context.Context, n int) ([]string, error) { 49 | if len(s.keys) == 0 { 50 | return nil, errors.New("no subscribed keys") 51 | } 52 | 53 | keys := make([]string, 0, len(s.keys)) 54 | for k := range s.keys { 55 | keys = append(keys, k) 56 | } 57 | 58 | requests, err := s.RouteLogger.WaitN(ctx, n, keys...) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | events := make([]string, len(requests)) 64 | for i, req := range requests { 65 | events[i] = req.Body 66 | } 67 | return events, nil 68 | } 69 | -------------------------------------------------------------------------------- /webhook/types.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/segmentio/events" 7 | ) 8 | 9 | type Request struct { 10 | // The remote IP address (with port) 11 | RemoteAddr string 12 | 13 | Method string 14 | Proto string 15 | RawURL string 16 | 17 | Header map[string][]string 18 | Body string 19 | 20 | ReceivedAt time.Time 21 | } 22 | 23 | type Logger interface { 24 | // TODO consider adding error 25 | Record(r Request) 26 | } 27 | 28 | type StdLogger struct { 29 | } 30 | 31 | func (s StdLogger) Record(r Request) { 32 | events.Log("%{request}+v", r) 33 | } 34 | 35 | func NewChanLogger(rc chan<- Request) ChanLogger { 36 | return ChanLogger{ 37 | rc: rc, 38 | } 39 | } 40 | 41 | type ChanLogger struct { 42 | rc chan<- Request 43 | } 44 | 45 | func (s ChanLogger) Record(r Request) { 46 | // Requests are dropped if writing chan would block 47 | select { 48 | case s.rc <- r: 49 | default: 50 | events.Debug("chan full, dropping request") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type Webhook struct { 10 | log Logger 11 | } 12 | 13 | type Config struct { 14 | Logger Logger 15 | } 16 | 17 | func New(c Config, opts ...func(*Webhook)) *Webhook { 18 | ret := &Webhook{ 19 | log: c.Logger, 20 | } 21 | for _, o := range opts { 22 | o(ret) 23 | } 24 | return ret 25 | } 26 | 27 | func (h *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | rdr := http.MaxBytesReader(w, r.Body, 1024*1024) 29 | defer rdr.Close() 30 | 31 | bs, err := ioutil.ReadAll(rdr) 32 | if err != nil { 33 | w.WriteHeader(http.StatusBadRequest) 34 | return 35 | } 36 | 37 | h.log.Record(Request{ 38 | RemoteAddr: r.RemoteAddr, 39 | Method: r.Method, 40 | Proto: r.Proto, 41 | RawURL: r.URL.String(), 42 | Header: cloneHeader(r.Header), 43 | Body: string(bs), 44 | ReceivedAt: time.Now().UTC(), 45 | }) 46 | } 47 | 48 | func (h *Webhook) Close() error { 49 | return nil 50 | } 51 | 52 | func cloneHeader(h http.Header) http.Header { 53 | h2 := make(http.Header, len(h)) 54 | for k, vv := range h { 55 | vv2 := make([]string, len(vv)) 56 | copy(vv2, vv) 57 | h2[k] = vv2 58 | } 59 | return h2 60 | } 61 | --------------------------------------------------------------------------------