├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── addresses.go ├── addresses_test.go ├── catalog ├── services_state.go ├── services_state_ffjson.go ├── services_state_test.go ├── url_listener.go ├── url_listener_test.go ├── view.go └── view_test.go ├── cli.go ├── config └── config.go ├── discovery ├── discovery.go ├── discovery_test.go ├── docker_discovery.go ├── docker_discovery_test.go ├── fixtures │ ├── bad-fixture │ │ └── token │ ├── ca.crt │ └── token ├── kubernetes_api_discovery.go ├── kubernetes_api_discovery_test.go ├── kubernetes_support.go ├── kubernetes_support_test.go ├── service_namer.go ├── service_namer_test.go ├── static_discovery.go └── static_discovery_test.go ├── docker ├── Dockerfile ├── README.md ├── build.sh ├── docker-compose.yml ├── run ├── run-services └── s6 │ └── s6 │ ├── .s6-svscan │ ├── crash │ └── finish │ └── sidecar.svc │ └── run ├── envoy ├── adapter │ ├── adapter.go │ └── adapter_test.go ├── server.go └── server_test.go ├── fixtures ├── static-hostnamed.json └── static.json ├── go.mod ├── go.sum ├── haproxy ├── haproxy.go └── haproxy_test.go ├── healthy ├── commands.go ├── healthy.go ├── healthy_test.go ├── service_bridge.go └── service_bridge_test.go ├── logging_bridge.go ├── logging_bridge_test.go ├── main.go ├── output ├── output.go └── output_test.go ├── receiver ├── http.go ├── http_test.go ├── receiver.go └── receiver_test.go ├── service ├── service.go ├── service_ffjson.go └── service_test.go ├── services_delegate.go ├── services_delegate_test.go ├── sidecarhttp ├── envoy_api.go ├── envoy_api_ffjson.go ├── envoy_api_test.go ├── http.go ├── http_api.go ├── http_api_test.go ├── http_listener.go └── http_test.go ├── ui ├── app │ ├── Sidecar.png │ ├── app.js │ ├── bower.json │ ├── components │ │ └── version │ │ │ ├── interpolate-filter.js │ │ │ ├── interpolate-filter_test.js │ │ │ ├── version-directive.js │ │ │ ├── version-directive_test.js │ │ │ ├── version.js │ │ │ └── version_test.js │ ├── css │ │ └── services.css │ ├── index-async.html │ ├── index.html │ └── services │ │ ├── services.html │ │ └── services.js ├── e2e-tests │ ├── protractor.conf.js │ └── scenarios.js ├── karma.conf.js └── package.json └── views ├── haproxy.cfg └── static ├── Sidecar Architecture.png ├── Sidecar Architecture.svg ├── Sidecar.png ├── sidecar-networking.png ├── youtube.png └── youtube2.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | sidecar 4 | sidecar.toml 5 | sidecar.cpu.prof 6 | docker/views 7 | bower_components 8 | node_modules 9 | npm-debug.log 10 | .idea/ 11 | .vscode/ 12 | .DS_Store 13 | ui/package-lock.json 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | 3 | language: go 4 | go: 5 | - 1.18.x 6 | 7 | services: 8 | - docker 9 | 10 | env: 11 | - GO111MODULE=on 12 | 13 | before_install: 14 | - nvm install node 15 | - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.23.1 16 | 17 | script: 18 | - go mod tidy && if [ ! -z "$( git status --porcelain go.mod go.sum )" ]; then exit 1; fi 19 | - golangci-lint run 20 | - go test -v --timeout 30s ./... && (CGO_ENABLED=0 GOOS=linux go build -ldflags '-d') 21 | - if [[ "$TRAVIS_BRANCH" == "master" ]] && [[ "${TRAVIS_GO_VERSION}" == "${PRODUCTION_GO_VERSION}"* ]]; then 22 | echo "Building container gonitro/sidecar:${TRAVIS_COMMIT::7}" && 23 | cd ui && npm install && cd .. && 24 | docker build -f docker/Dockerfile -t sidecar . && 25 | docker tag sidecar gonitro/sidecar:${TRAVIS_COMMIT::7} && 26 | docker tag sidecar gonitro/sidecar:latest; 27 | fi 28 | 29 | after_success: 30 | - docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" 31 | - echo "Building on Go version ${TRAVIS_GO_VERSION} for branch ${TRAVIS_BRANCH}" 32 | - if [[ "$TRAVIS_BRANCH" == "master" ]] && [[ "${TRAVIS_GO_VERSION}" == "${PRODUCTION_GO_VERSION}"* ]]; then 33 | echo "Pushing container gonitro/sidecar:${TRAVIS_COMMIT::7}" && 34 | docker push gonitro/sidecar:${TRAVIS_COMMIT::7}; 35 | fi 36 | - if [[ "$TRAVIS_BRANCH" == "master" ]] && [[ "${TRAVIS_GO_VERSION}" == "${PRODUCTION_GO_VERSION}"* ]] && [ -z "${NO_PUSH_LATEST}" ]; then 37 | docker push gonitro/sidecar:latest; 38 | fi 39 | 40 | deploy: 41 | provider: releases 42 | 43 | api_key: 44 | secure: "LurQmR3FD+tWZ/x+EN/jLVcaqojpaeK5ALyuv1iTgmjkvF0z2qMuMU1gOivRNDMxfBVSlJFe14B1D03VizlKhm+Ee2ZBIkri32APVM/0bum2y9yQvHHQuRiXzuWu+ZgPNlse5/lMIIdMqe2yzRkLeXvdpytSwcJ8OMWW9KHXWdh4KgWS49L36ce8cpGHAj+lf7fZ7V9VVZKK56ZzZZ/ZaTh55RBkMTo5/uKDehSFZ9m9q6D9l5NjVe3tnUyYVLYhDkVEWxdu+40h56mWVPoAq2Wl9jFbZ5fdi9Q+OA7UsAevqleGqktBLZgc/xZ47RpwPEVRrPA2QvRJm4KcXhZsZS6aOqpWNwGYWXOQJ95uj7KIrT6xwZV7qCj7aKtge0YdkG/CIQn1NvJ7zriFjbr6YhkvMFE3ZzDjLpzZiNsdRYuNC9xNJXD8LTnEa6ih/KjEQdn8DGdgbJaNZGgH0KCJ0P4LkTbdCUfvJTPV4+pJBHDhUJAYNvqBUHgJ9VCmg72vUtSUibeJoqYOD3RwytEcMsXGPKT8FeI7XZ/a7khGPXSVdombyT9M2IPjCwBhnQJSb6NfCyy4aTkvgwXm218h2Rg5ESBQRADf3LL4p6l/S2COv34Jb72GLYTUU1QXoDZ2Ft5rueYYctuH8fBfWSbWIMi9xNznJctUauKcVGsJ3V4=" 45 | 46 | file: sidecar 47 | 48 | skip_cleanup: true 49 | 50 | on: 51 | repo: Nitro/sidecar 52 | tags: true 53 | condition: ${TRAVIS_GO_VERSION} == ${PRODUCTION_GO_VERSION}* 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 New Relic, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME = sidecar 2 | APP_VSN ?= $(shell git rev-parse --short HEAD) 3 | 4 | .PHONY: help 5 | help: #: Show this help message 6 | @echo "$(APP_NAME):$(APP_VSN)" 7 | @awk '/^[A-Za-z_ -]*:.*#:/ {printf("%c[1;32m%-15s%c[0m", 27, $$1, 27); for(i=3; i<=NF; i++) { printf("%s ", $$i); } printf("\n"); }' Makefile* | sort 8 | 9 | CGO_ENABLED ?= 0 10 | GO = GO_ENABLED=$(CGO_ENABLED) go 11 | GO_BUILD_FLAGS = -ldflags "-X main.Version=${APP_VSN}" 12 | 13 | ### Dev 14 | 15 | .PHONY: run 16 | run: #: Run the application 17 | $(GO) run $(GO_BUILD_FLAGS) `ls -1 *.go | grep -v _test.go` 18 | 19 | .PHONY: code-check 20 | code-check: 21 | golangci-lint run 22 | 23 | ### Build 24 | 25 | .PHONY: build 26 | build: #: Build the app locally 27 | build: clean 28 | GOOS=linux $(GO) build $(GO_BUILD_FLAGS) -o $(APP_NAME) 29 | ./docker/build.sh 30 | 31 | .PHONY: release 32 | release: #: Build and upload the release to GitHub 33 | goreleaser 34 | 35 | .PHONY: clean 36 | clean: #: Clean up build artifacts 37 | clean: 38 | $(RM) ./$(APP_NAME) 39 | 40 | ### Test 41 | 42 | .PHONY: test 43 | test: #: Run Go unit tests 44 | test: 45 | GO111MODULE=on $(GO) test -v ./... 46 | -------------------------------------------------------------------------------- /addresses.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | ) 7 | 8 | var privateBlocks []*net.IPNet 9 | 10 | func setupIPBlocks() { 11 | privateBlockStrs := []string{ 12 | "10.0.0.0/8", 13 | "172.16.0.0/12", 14 | "192.168.0.0/16", 15 | } 16 | 17 | privateBlocks = make([]*net.IPNet, len(privateBlockStrs)) 18 | 19 | for i, blockStr := range privateBlockStrs { 20 | _, block, _ := net.ParseCIDR(blockStr) 21 | privateBlocks[i] = block 22 | } 23 | } 24 | 25 | func isPrivateIP(ip_str string) bool { 26 | ip := net.ParseIP(ip_str) 27 | 28 | for _, priv := range privateBlocks { 29 | if priv.Contains(ip) { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | 36 | func findPrivateAddresses() ([]*net.IP, error) { 37 | if len(privateBlocks) < 1 { 38 | setupIPBlocks() 39 | } 40 | 41 | addresses, err := net.InterfaceAddrs() 42 | if err != nil { 43 | return nil, errors.New( 44 | "Failed to get interface addresses! Err: " + err.Error(), 45 | ) 46 | } 47 | 48 | result := make([]*net.IP, 0, len(addresses)) 49 | 50 | // Find private IPv4 address 51 | for _, rawAddr := range addresses { 52 | var ip net.IP 53 | switch addr := rawAddr.(type) { 54 | case *net.IPAddr: 55 | ip = addr.IP 56 | case *net.IPNet: 57 | ip = addr.IP 58 | default: 59 | continue 60 | } 61 | 62 | if ip.To4() == nil { 63 | continue 64 | } 65 | 66 | if isPrivateIP(ip.String()) { 67 | result = append(result, &ip) 68 | } 69 | } 70 | 71 | err = nil 72 | 73 | if len(result) < 1 { 74 | err = errors.New("No addresses found!") 75 | result = nil 76 | } 77 | 78 | return result, err 79 | } 80 | 81 | func getPublishedIP(excluded []string, advertise string) (string, error) { 82 | if advertise != "" { 83 | return advertise, nil 84 | } 85 | 86 | addresses, _ := findPrivateAddresses() 87 | 88 | OUTER: 89 | for _, address := range addresses { 90 | for _, excludeIP := range excluded { 91 | if address.String() == excludeIP { 92 | continue OUTER 93 | } 94 | } 95 | return address.String(), nil 96 | } 97 | 98 | return "", errors.New("Can't find address!") 99 | } 100 | -------------------------------------------------------------------------------- /addresses_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func Test_Addresses(t *testing.T) { 10 | Convey("setupIPBlocks()", t, func() { 11 | Convey("Sets up the right number of blocks", func() { 12 | setupIPBlocks() 13 | So(len(privateBlocks), ShouldEqual, 3) 14 | }) 15 | }) 16 | 17 | Convey("isPrivateIP()", t, func() { 18 | Convey("Can tell whether an address is private", func() { 19 | So(isPrivateIP("172.16.54.3"), ShouldBeTrue) 20 | So(isPrivateIP("12.1.1.1"), ShouldBeFalse) 21 | }) 22 | }) 23 | 24 | Convey("findPrivateAddresses()", t, func() { 25 | // Note that this actually looks at interfaces on the machine. 26 | // Almost all machines will pass this test. It's possible it might 27 | // fail on a machine that has only a public Internet IP. 28 | Convey("Finds at least one private address", func() { 29 | addresses, err := findPrivateAddresses() 30 | So(err, ShouldBeNil) 31 | So(len(addresses), ShouldBeGreaterThan, 0) 32 | }) 33 | }) 34 | 35 | Convey("getPublishedIP()", t, func() { 36 | ip := "10.10.10.10" 37 | 38 | Convey("Returns the advertised IP if supplied", func() { 39 | result, err := getPublishedIP([]string{}, ip) 40 | So(err, ShouldBeNil) 41 | So(result, ShouldEqual, ip) 42 | }) 43 | 44 | // See caveat for findPrivateAddresses() above 45 | Convey("Returns an address", func() { 46 | addresses, _ := findPrivateAddresses() 47 | result, err := getPublishedIP([]string{}, "") 48 | 49 | So(err, ShouldBeNil) 50 | So(result, ShouldResemble, addresses[0].String()) 51 | }) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /catalog/url_listener.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/cookiejar" 9 | "net/url" 10 | "os" 11 | "time" 12 | 13 | "github.com/relistan/go-director" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const ( 18 | ClientTimeout = 3 * time.Second 19 | DefaultRetries = 5 20 | ) 21 | 22 | // An UrlListener is an event listener that receives updates over an 23 | // HTTP POST to an endpoint. 24 | type UrlListener struct { 25 | Url string 26 | Retries int 27 | Client *http.Client 28 | looper director.Looper 29 | eventChannel chan ChangeEvent 30 | managed bool // Is this to be auto-managed by ServicesState? 31 | name string 32 | } 33 | 34 | // A StateChangedEvent is sent to UrlListeners when a significant 35 | // event has changed the ServicesState. 36 | type StateChangedEvent struct { 37 | State *ServicesState 38 | ChangeEvent ChangeEvent 39 | } 40 | 41 | func prepareCookieJar(listenurl string) *cookiejar.Jar { 42 | cookieJar, err := cookiejar.New(nil) 43 | hostname, err2 := os.Hostname() 44 | cookieUrl, err3 := url.Parse(listenurl) 45 | 46 | if err != nil || err2 != nil || err3 != nil { 47 | log.Errorf("Failed to prepare HTTP cookie jar for UrlListener(%s)", listenurl) 48 | return nil 49 | } 50 | 51 | expiration := time.Now().Add(365 * 24 * time.Hour) 52 | cookie := &http.Cookie{ 53 | Name: "sidecar-session-host", 54 | Value: hostname + "-" + time.Now().UTC().String(), 55 | Expires: expiration, 56 | } 57 | 58 | cookieJar.SetCookies(cookieUrl, []*http.Cookie{cookie}) 59 | 60 | return cookieJar 61 | } 62 | 63 | func NewUrlListener(listenurl string, managed bool) *UrlListener { 64 | errorChan := make(chan error, 1) 65 | 66 | // Primarily for the purpose of load balancers that look 67 | // at a cookie for session affinity. 68 | cookieJar := prepareCookieJar(listenurl) 69 | 70 | return &UrlListener{ 71 | Url: listenurl, 72 | looper: director.NewFreeLooper(director.FOREVER, errorChan), 73 | Client: &http.Client{Timeout: ClientTimeout, Jar: cookieJar}, 74 | eventChannel: make(chan ChangeEvent, LISTENER_EVENT_BUFFER_SIZE), 75 | Retries: DefaultRetries, 76 | managed: managed, 77 | name: "UrlListener(" + listenurl + ")", 78 | } 79 | } 80 | 81 | func withRetries(count int, fn func() error) error { 82 | var result error 83 | 84 | for i := -1; i < count; i++ { 85 | result = fn() 86 | if result == nil { 87 | return nil 88 | } 89 | time.Sleep(100 * time.Duration(i) * time.Millisecond) 90 | } 91 | 92 | log.Warnf("Failed after %d retries", count) 93 | return result 94 | } 95 | 96 | func (u *UrlListener) Name() string { 97 | return u.name 98 | } 99 | 100 | func (u *UrlListener) SetName(name string) { 101 | u.name = name 102 | } 103 | 104 | func (u *UrlListener) Chan() chan ChangeEvent { 105 | return u.eventChannel 106 | } 107 | 108 | func (u *UrlListener) Managed() bool { 109 | return u.managed 110 | } 111 | 112 | func (u *UrlListener) Stop() { 113 | u.looper.Quit() 114 | } 115 | 116 | func (u *UrlListener) Watch(state *ServicesState) { 117 | state.AddListener(u) 118 | 119 | go func() { 120 | u.looper.Loop(func() error { 121 | changedServiceEvent := <-u.eventChannel 122 | 123 | state.RLock() 124 | event := StateChangedEvent{ 125 | State: state, 126 | ChangeEvent: changedServiceEvent, 127 | } 128 | 129 | data, err := json.Marshal(event) 130 | state.RUnlock() 131 | 132 | // Check for some kind of junk JSON being generated by state.Encode() 133 | if err != nil { 134 | log.Warnf("Skipping post to '%s' because of bad state encoding! (%s)", u.Url, err.Error()) 135 | return nil 136 | } 137 | 138 | buf := bytes.NewBuffer(data) 139 | 140 | err = withRetries(u.Retries, func() error { 141 | resp, err := u.Client.Post(u.Url, "application/json", buf) 142 | 143 | if err != nil { 144 | return err 145 | } 146 | 147 | if resp.StatusCode > 299 || resp.StatusCode < 200 { 148 | return fmt.Errorf("Bad status code returned (%d)", resp.StatusCode) 149 | } 150 | 151 | return nil 152 | }) 153 | 154 | if err != nil { 155 | log.Warnf("Failed posting state to '%s' %s: %s", u.Url, u.Name(), err.Error()) 156 | } 157 | 158 | return nil 159 | }) 160 | }() 161 | } 162 | -------------------------------------------------------------------------------- /catalog/url_listener_test.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | "time" 8 | 9 | "github.com/NinesStack/sidecar/service" 10 | "github.com/relistan/go-director" 11 | . "github.com/smartystreets/goconvey/convey" 12 | "gopkg.in/jarcoal/httpmock.v1" 13 | ) 14 | 15 | func Test_NewUrlListener(t *testing.T) { 16 | Convey("NewUrlListener() configures all the right things", t, func() { 17 | url := "http://beowulf.example.com" 18 | listener := NewUrlListener(url, false) 19 | 20 | So(listener.Client, ShouldNotBeNil) 21 | So(listener.Url, ShouldEqual, url) 22 | So(listener.looper, ShouldNotBeNil) 23 | }) 24 | } 25 | 26 | func Test_prepareCookieJar(t *testing.T) { 27 | Convey("When preparing the cookie jar", t, func() { 28 | listenurl := "http://beowulf.example.com/" 29 | 30 | Convey("We get a properly generated cookie for our url", func() { 31 | jar := prepareCookieJar(listenurl) 32 | cookieUrl, _ := url.Parse(listenurl) 33 | cookies := jar.Cookies(cookieUrl) 34 | 35 | So(len(cookies), ShouldEqual, 1) 36 | So(cookies[0].Value, ShouldNotBeEmpty) 37 | So(cookies[0].Expires, ShouldNotBeEmpty) 38 | }) 39 | 40 | Convey("We only get the right cookies", func() { 41 | jar := prepareCookieJar(listenurl) 42 | wrongUrl, _ := url.Parse("http://wrong.example.com") 43 | cookies := jar.Cookies(wrongUrl) 44 | 45 | So(len(cookies), ShouldEqual, 0) 46 | }) 47 | }) 48 | } 49 | 50 | func Test_Listen(t *testing.T) { 51 | Convey("Listen()", t, func() { 52 | url := "http://beowulf.example.com" 53 | 54 | httpmock.RegisterResponder( 55 | "POST", url, 56 | func(req *http.Request) (*http.Response, error) { 57 | return httpmock.NewStringResponse(500, "so bad!"), nil 58 | }, 59 | ) 60 | 61 | httpmock.Activate() 62 | listener := NewUrlListener(url, false) 63 | errors := make(chan error) 64 | listener.looper = director.NewFreeLooper(1, errors) 65 | 66 | hostname := "grendel" 67 | 68 | svcId1 := "deadbeef123" 69 | service1 := service.Service{ID: svcId1, Hostname: hostname, Updated: time.Now().UTC()} 70 | 71 | state := NewServicesState() 72 | state.Hostname = hostname 73 | state.AddServiceEntry(service1) 74 | state.Servers[hostname].Services[service1.ID].Tombstone() 75 | 76 | Reset(func() { 77 | httpmock.DeactivateAndReset() 78 | }) 79 | 80 | Convey("handles a bad post", func() { 81 | listener.eventChannel <- ChangeEvent{} 82 | listener.Retries = 0 83 | listener.Watch(state) 84 | err := listener.looper.Wait() 85 | 86 | So(err, ShouldBeNil) 87 | So(len(errors), ShouldEqual, 0) 88 | }) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /catalog/view.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/NinesStack/memberlist" 7 | "github.com/NinesStack/sidecar/service" 8 | ) 9 | 10 | // These are functions useful in viewing the contents of the state 11 | 12 | // ServicesState ------------------------- 13 | 14 | func (state *ServicesState) EachServiceSorted(fn func(hostname *string, serviceId *string, svc *service.Service)) { 15 | var services []*service.Service 16 | state.EachService(func(hostname *string, serviceId *string, svc *service.Service) { 17 | services = append(services, svc) 18 | }) 19 | 20 | sort.Sort(ServicesByAge(services)) 21 | 22 | for _, svc := range services { 23 | fn(&svc.Hostname, &svc.ID, svc) 24 | } 25 | } 26 | 27 | func (state *ServicesState) EachLocalService(fn func(hostname *string, serviceId *string, svc *service.Service)) { 28 | state.EachService(func(hostname *string, serviceId *string, svc *service.Service) { 29 | if state.Hostname == *hostname { 30 | fn(hostname, serviceId, svc) 31 | } 32 | }) 33 | } 34 | 35 | // Services ------------------------------- 36 | // 37 | // by Age 38 | type ServicesByAge []*service.Service 39 | 40 | func (s ServicesByAge) Len() int { return len(s) } 41 | func (s ServicesByAge) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 42 | func (s ServicesByAge) Less(i, j int) bool { return s[i].Updated.Before(s[j].Updated) } 43 | 44 | func (s *Server) SortedServices() []*service.Service { 45 | servicesList := make([]*service.Service, 0, len(s.Services)) 46 | 47 | for _, service := range s.Services { 48 | servicesList = append(servicesList, service) 49 | } 50 | 51 | sort.Sort(ServicesByAge(servicesList)) 52 | 53 | return servicesList 54 | } 55 | 56 | // by Name 57 | type ServicesByName []*service.Service 58 | 59 | func (a ServicesByName) Len() int { return len(a) } 60 | func (a ServicesByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 61 | func (a ServicesByName) Less(i, j int) bool { return a[i].Name < a[j].Name } 62 | 63 | // Servers -------------------------------- 64 | type ServerByName []*Server 65 | 66 | func (s ServerByName) Len() int { 67 | return len(s) 68 | } 69 | 70 | func (s ServerByName) Swap(i, j int) { 71 | s[i], s[j] = s[j], s[i] 72 | } 73 | 74 | func (s ServerByName) Less(i, j int) bool { 75 | return s[i].Name < s[j].Name 76 | } 77 | 78 | func (state *ServicesState) SortedServers() []*Server { 79 | serversList := make([]*Server, 0, len(state.Servers)) 80 | 81 | for _, server := range state.Servers { 82 | serversList = append(serversList, server) 83 | } 84 | 85 | sort.Sort(ServerByName(serversList)) 86 | 87 | return serversList 88 | } 89 | 90 | // Memberlist -------------------------------- 91 | // 92 | // by Name 93 | type ListByName []*memberlist.Node 94 | 95 | func (a ListByName) Len() int { return len(a) } 96 | func (a ListByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 97 | func (a ListByName) Less(i, j int) bool { return a[i].Name < a[j].Name } 98 | -------------------------------------------------------------------------------- /catalog/view_test.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/NinesStack/sidecar/service" 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | var ( 12 | hostname1 string = "shakespeare" 13 | hostname2 string = "chaucer" 14 | hostname3 string = "bocaccio" 15 | ) 16 | 17 | func Test_ServerSorting(t *testing.T) { 18 | 19 | Convey("Sorting", t, func() { 20 | state := NewServicesState() 21 | svcId1 := "deadbeef123" 22 | svcId2 := "deadbeef101" 23 | svcId3 := "deadbeef105" 24 | baseTime := time.Now().UTC().Round(time.Second) 25 | 26 | service1 := service.Service{ID: svcId1, Hostname: hostname1, Updated: baseTime.Add(5 * time.Second)} 27 | service2 := service.Service{ID: svcId2, Hostname: hostname2, Updated: baseTime} 28 | service3 := service.Service{ID: svcId3, Hostname: hostname3, Updated: baseTime.Add(10 * time.Second)} 29 | 30 | state.Hostname = hostname 31 | 32 | state.AddServiceEntry(service1) 33 | state.AddServiceEntry(service2) 34 | state.AddServiceEntry(service3) 35 | 36 | Convey("Returns a list of Servers sorted by Name", func() { 37 | sortedServers := state.SortedServers() 38 | names := make([]string, 0, len(sortedServers)) 39 | 40 | for _, server := range sortedServers { 41 | names = append(names, server.Name) 42 | } 43 | 44 | should := []string{"bocaccio", "chaucer", "shakespeare"} 45 | for i, id := range should { 46 | So(names[i], ShouldEqual, id) 47 | } 48 | }) 49 | 50 | Convey("Returns a list of Services sorted by Updates", func() { 51 | service1 := service.Service{ID: svcId1, Hostname: hostname3, Updated: baseTime.Add(5 * time.Second)} 52 | service2 := service.Service{ID: svcId2, Hostname: hostname3, Updated: baseTime} 53 | service3 := service.Service{ID: svcId3, Hostname: hostname3, Updated: baseTime.Add(10 * time.Second)} 54 | 55 | state.AddServiceEntry(service3) 56 | state.AddServiceEntry(service2) 57 | state.AddServiceEntry(service1) 58 | 59 | sortedServices := state.Servers[hostname3].SortedServices() 60 | ids := make([]string, 0, len(sortedServices)) 61 | 62 | for _, service := range sortedServices { 63 | ids = append(ids, service.ID) 64 | } 65 | 66 | So(ids[0], ShouldEqual, svcId2) 67 | So(ids[1], ShouldEqual, svcId1) 68 | So(ids[2], ShouldEqual, svcId3) 69 | }) 70 | 71 | Convey("Returs a list of Services sorted on sorted Servers", func() { 72 | service4 := service.Service{ID: svcId1, Hostname: hostname3, Updated: baseTime.Add(5 * time.Second)} 73 | service5 := service.Service{ID: svcId2, Hostname: hostname3, Updated: baseTime} 74 | service6 := service.Service{ID: svcId3, Hostname: hostname3, Updated: baseTime.Add(10 * time.Second)} 75 | 76 | state.AddServiceEntry(service4) 77 | state.AddServiceEntry(service5) 78 | state.AddServiceEntry(service6) 79 | 80 | services := make([]string, 0, 10) 81 | 82 | state.EachServiceSorted(func(hostname *string, serviceId *string, svc *service.Service) { 83 | services = append(services, svc.ID) 84 | }) 85 | 86 | should := []string{"deadbeef101", "deadbeef101", "deadbeef123", "deadbeef123", "deadbeef105"} 87 | for i, id := range should { 88 | So(services[i], ShouldEqual, id) 89 | } 90 | }) 91 | 92 | }) 93 | 94 | } 95 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gopkg.in/alecthomas/kingpin.v2" 8 | ) 9 | 10 | type CliOpts struct { 11 | AdvertiseIP *string 12 | ClusterIPs *[]string 13 | ClusterName *string 14 | CpuProfile *bool 15 | Discover *[]string 16 | LoggingLevel *string 17 | } 18 | 19 | func exitWithError(err error, message string) { 20 | if err != nil { 21 | log.Fatalf("%s (%s)", message, err.Error()) 22 | } 23 | } 24 | 25 | func parseCommandLine() *CliOpts { 26 | var opts CliOpts 27 | 28 | app := kingpin.New("sidecar", "") 29 | 30 | opts.AdvertiseIP = app.Flag("advertise-ip", "The address to advertise to the cluster").Short('a').String() 31 | opts.ClusterIPs = app.Flag("cluster-ip", "The cluster seed addresses").Short('c').NoEnvar().Strings() 32 | opts.ClusterName = app.Flag("cluster-name", "The cluster we're part of").Short('n').String() 33 | opts.CpuProfile = app.Flag("cpuprofile", "Enable CPU profiling").Short('p').Bool() 34 | opts.Discover = app.Flag("discover", "Method of discovery").Short('d').NoEnvar().Strings() 35 | opts.LoggingLevel = app.Flag("logging-level", "Set the logging level").Short('l').String() 36 | 37 | _, err := app.Parse(os.Args[1:]) 38 | exitWithError(err, "Failed to parse CLI opts") 39 | 40 | return &opts 41 | } 42 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/kelseyhightower/envconfig" 7 | log "github.com/sirupsen/logrus" 8 | "gopkg.in/relistan/rubberneck.v1" 9 | ) 10 | 11 | type ListenerUrlsConfig struct { 12 | Urls []string `envconfig:"URLS"` 13 | } 14 | 15 | type HAproxyConfig struct { 16 | ReloadCmd string `envconfig:"RELOAD_COMMAND"` 17 | VerifyCmd string `envconfig:"VERIFY_COMMAND"` 18 | BindIP string `envconfig:"BIND_IP" default:"192.168.168.168"` 19 | TemplateFile string `envconfig:"TEMPLATE_FILE" default:"views/haproxy.cfg"` 20 | ConfigFile string `envconfig:"CONFIG_FILE" default:"/etc/haproxy.cfg"` 21 | PidFile string `envconfig:"PID_FILE" default:"/var/run/haproxy.pid"` 22 | Disable bool `envconfig:"DISABLE"` 23 | User string `envconfig:"USER" default:"haproxy"` 24 | Group string `envconfig:"GROUP" default:""` 25 | UseHostnames bool `envconfig:"USE_HOSTNAMES"` 26 | } 27 | 28 | type EnvoyConfig struct { 29 | UseGRPCAPI bool `envconfig:"USE_GRPC_API" default:"true"` 30 | BindIP string `envconfig:"BIND_IP" default:"192.168.168.168"` 31 | UseHostnames bool `envconfig:"USE_HOSTNAMES"` 32 | GRPCPort string `envconfig:"GRPC_PORT" default:"7776"` 33 | LoggingLevel string `envconfig:"LOGGING_LEVEL" default:"info"` 34 | } 35 | 36 | type ServicesConfig struct { 37 | NameMatch string `envconfig:"NAME_MATCH"` 38 | ServiceNamer string `envconfig:"NAMER" default:"docker_label"` 39 | NameLabel string `envconfig:"NAME_LABEL" default:"ServiceName"` 40 | } 41 | 42 | type SidecarConfig struct { 43 | ExcludeIPs []string `envconfig:"EXCLUDE_IPS" default:"192.168.168.168"` 44 | Discovery []string `envconfig:"DISCOVERY" default:"docker"` 45 | StatsAddr string `envconfig:"STATS_ADDR"` 46 | PushPullInterval time.Duration `envconfig:"PUSH_PULL_INTERVAL" default:"20s"` 47 | GossipMessages int `envconfig:"GOSSIP_MESSAGES" default:"15"` 48 | GossipInterval time.Duration `envconfig:"GOSSIP_INTERVAL" default:"200ms"` 49 | HandoffQueueDepth int `envconfig:"HANDOFF_QUEUE_DEPTH" default:"1024"` 50 | LoggingFormat string `envconfig:"LOGGING_FORMAT"` 51 | LoggingLevel string `envconfig:"LOGGING_LEVEL" default:"info"` 52 | DefaultCheckEndpoint string `envconfig:"DEFAULT_CHECK_ENDPOINT" default:"/version"` 53 | Seeds []string `envconfig:"SEEDS"` 54 | ClusterName string `envconfig:"CLUSTER_NAME" default:"default"` 55 | AdvertiseIP string `envconfig:"ADVERTISE_IP"` 56 | BindPort int `envconfig:"BIND_PORT" default:"7946"` 57 | Debug bool `envconfig:"DEBUG" default:"false"` 58 | DiscoverySleepInterval time.Duration `envconfig:"DISCOVERY_SLEEP_INTERVAL" default:"1s"` 59 | } 60 | 61 | type DockerConfig struct { 62 | DockerURL string `envconfig:"URL" default:"unix:///var/run/docker.sock"` 63 | } 64 | 65 | type StaticConfig struct { 66 | ConfigFile string `envconfig:"CONFIG_FILE" default:"static.json"` 67 | } 68 | 69 | type K8sAPIConfig struct { 70 | KubeAPIIP string `envconfig:"KUBE_API_IP" default:"127.0.0.1"` 71 | KubeAPIPort int `envconfig:"KUBE_API_PORT" default:"8080"` 72 | Namespace string `envconfig:"NAMESPACE" default:"default"` 73 | KubeTimeout time.Duration `envconfig:"KUBE_TIMEOUT" default:"3s"` 74 | CredsPath string `envconfig:"CREDS_PATH" default:"/var/run/secrets/kubernetes.io/serviceaccount"` 75 | AnnounceAllNodes bool `envconfig:"ANNOUNCE_ALL_NODES" default:"false"` 76 | } 77 | 78 | type Config struct { 79 | Sidecar SidecarConfig // SIDECAR_ 80 | DockerDiscovery DockerConfig // DOCKER_ 81 | StaticDiscovery StaticConfig // STATIC_ 82 | K8sAPIDiscovery K8sAPIConfig // K8S_ 83 | Services ServicesConfig // SERVICES_ 84 | HAproxy HAproxyConfig // HAPROXY_ 85 | Envoy EnvoyConfig // ENVOY_ 86 | Listeners ListenerUrlsConfig // LISTENERS_ 87 | } 88 | 89 | func ParseConfig() *Config { 90 | var config Config 91 | 92 | errs := []error{ 93 | envconfig.Process("sidecar", &config.Sidecar), 94 | envconfig.Process("docker", &config.DockerDiscovery), 95 | envconfig.Process("static", &config.StaticDiscovery), 96 | envconfig.Process("k8s", &config.K8sAPIDiscovery), 97 | envconfig.Process("services", &config.Services), 98 | envconfig.Process("haproxy", &config.HAproxy), 99 | envconfig.Process("envoy", &config.Envoy), 100 | envconfig.Process("listeners", &config.Listeners), 101 | } 102 | 103 | for _, err := range errs { 104 | if err != nil { 105 | rubberneck.Print(config) 106 | log.Fatalf("Can't parse environment config: %s", err) 107 | } 108 | } 109 | 110 | return &config 111 | } 112 | -------------------------------------------------------------------------------- /discovery/discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NinesStack/sidecar/service" 7 | "github.com/relistan/go-director" 8 | ) 9 | 10 | const ( 11 | DefaultSleepInterval = 1 * time.Second 12 | ) 13 | 14 | // A ChangeListener is a service that will receive service change events 15 | // over the HTTP interface. 16 | type ChangeListener struct { 17 | Name string // Name to be represented in the Listeners list 18 | Url string // Url of the service to send events to 19 | } 20 | 21 | // A Discoverer is responsible for finding services that we care 22 | // about. It must have a method to return the list of services, and 23 | // a Run() method that will be invoked when the discovery mechanism(s) 24 | // is/are started. It will also return the correct health check for 25 | // a service and can allow services to subscribe to Sidecar events. 26 | type Discoverer interface { 27 | // Returns a slice of services that we discovered 28 | Services() []service.Service 29 | // Get the health check and health check args for a service 30 | HealthCheck(svc *service.Service) (string, string) 31 | // Services which run on the same host and want to receive 32 | // Sidecar service change events 33 | Listeners() []ChangeListener 34 | // A non-blocking method that runs a discovery loop. 35 | // The controlling process kicks it off to start discovery. 36 | Run(director.Looper) 37 | } 38 | 39 | // A MultiDiscovery is a wrapper around zero or more Discoverers. 40 | // It allows the use of potentially multiple Discoverers in place of one. 41 | type MultiDiscovery struct { 42 | Discoverers []Discoverer 43 | } 44 | 45 | // Get the health check and health check args for a service 46 | func (d *MultiDiscovery) HealthCheck(svc *service.Service) (string, string) { 47 | for _, disco := range d.Discoverers { 48 | if healthCheck, healthCheckArgs := disco.HealthCheck(svc); healthCheck != "" { 49 | return healthCheck, healthCheckArgs 50 | } 51 | 52 | } 53 | return "", "" 54 | } 55 | 56 | // Aggregates all the service slices from the discoverers 57 | func (d *MultiDiscovery) Services() []service.Service { 58 | var aggregate []service.Service 59 | 60 | for _, disco := range d.Discoverers { 61 | services := disco.Services() 62 | if len(services) > 0 { 63 | aggregate = append(aggregate, services...) 64 | } 65 | } 66 | 67 | return aggregate 68 | } 69 | 70 | // Aggreates all the Listeners() output from the discoverers 71 | func (d *MultiDiscovery) Listeners() []ChangeListener { 72 | var aggregate []ChangeListener 73 | 74 | for _, disco := range d.Discoverers { 75 | subscribers := disco.Listeners() 76 | if len(subscribers) > 0 { 77 | aggregate = append(aggregate, subscribers...) 78 | } 79 | } 80 | 81 | return aggregate 82 | } 83 | 84 | // Kicks off the Run() method for all the discoverers. 85 | func (d *MultiDiscovery) Run(looper director.Looper) { 86 | var loopers []director.Looper 87 | 88 | for _, disco := range d.Discoverers { 89 | l := director.NewFreeLooper(director.FOREVER, make(chan error)) 90 | loopers = append(loopers, l) 91 | disco.Run(l) 92 | } 93 | 94 | // Waiting for a quit on the Looper's channel 95 | looper.Loop(func() error { 96 | return nil 97 | }) 98 | 99 | for _, l := range loopers { 100 | l.Quit() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /discovery/discovery_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/NinesStack/sidecar/service" 7 | "github.com/relistan/go-director" 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | type mockDiscoverer struct { 12 | ServicesList []service.Service 13 | RunInvoked bool 14 | ServicesInvoked bool 15 | Done chan error 16 | CheckName string 17 | ListenersInvoked bool 18 | ListenersList []ChangeListener 19 | } 20 | 21 | func (m *mockDiscoverer) Services() []service.Service { 22 | m.ServicesInvoked = true 23 | return m.ServicesList 24 | } 25 | 26 | func (m *mockDiscoverer) Listeners() []ChangeListener { 27 | m.ListenersInvoked = true 28 | return m.ListenersList 29 | } 30 | 31 | func (m *mockDiscoverer) Run(looper director.Looper) { 32 | m.RunInvoked = true 33 | } 34 | 35 | func (m *mockDiscoverer) HealthCheck(svc *service.Service) (string, string) { 36 | for _, aSvc := range m.ServicesList { 37 | if svc.Name == aSvc.Name { 38 | return m.CheckName, "" 39 | } 40 | } 41 | 42 | return "", "" 43 | } 44 | 45 | func Test_MultiDiscovery(t *testing.T) { 46 | Convey("MultiDiscovery", t, func() { 47 | looper := director.NewFreeLooper(director.ONCE, nil) 48 | 49 | done1 := make(chan error, 1) 50 | done2 := make(chan error, 1) 51 | 52 | svc1 := service.Service{Name: "svc1", ID: "1"} 53 | svc2 := service.Service{Name: "svc2", ID: "2"} 54 | 55 | disco1 := &mockDiscoverer{ 56 | []service.Service{svc1}, false, false, done1, "one", 57 | false, []ChangeListener{{Name: "svc1-1", Url: "http://localhost:10000"}}, 58 | } 59 | disco2 := &mockDiscoverer{ 60 | []service.Service{svc2}, false, false, done2, "two", 61 | false, []ChangeListener{{Name: "svc2-2", Url: "http://localhost:10000"}}, 62 | } 63 | 64 | multi := &MultiDiscovery{[]Discoverer{disco1, disco2}} 65 | 66 | Convey("Run() invokes the Run() method for all the discoverers", func() { 67 | multi.Run(looper) 68 | 69 | So(disco1.RunInvoked, ShouldBeTrue) 70 | So(disco2.RunInvoked, ShouldBeTrue) 71 | }) 72 | 73 | Convey("Services() invokes the Services() method for all the discoverers", func() { 74 | multi.Services() 75 | 76 | So(disco1.ServicesInvoked, ShouldBeTrue) 77 | So(disco2.ServicesInvoked, ShouldBeTrue) 78 | }) 79 | 80 | Convey("Services() aggregates all the service lists", func() { 81 | services := multi.Services() 82 | 83 | So(len(services), ShouldEqual, 2) 84 | So(services[0].Name, ShouldEqual, "svc1") 85 | So(services[1].Name, ShouldEqual, "svc2") 86 | }) 87 | 88 | Convey("Listeners() invokes the Listeners() method for all the discoverers", func() { 89 | multi.Listeners() 90 | 91 | So(disco1.ListenersInvoked, ShouldBeTrue) 92 | So(disco2.ListenersInvoked, ShouldBeTrue) 93 | }) 94 | 95 | Convey("Listeners() aggregates all the listener lists", func() { 96 | listeners := multi.Listeners() 97 | 98 | So(len(listeners), ShouldEqual, 2) 99 | So(listeners[0].Name, ShouldEqual, "svc1-1") 100 | So(listeners[1].Name, ShouldEqual, "svc2-2") 101 | }) 102 | 103 | Convey("HealthCheck() aggregates all the health checks", func() { 104 | check1, _ := multi.HealthCheck(&svc1) 105 | check2, _ := multi.HealthCheck(&svc2) 106 | 107 | So(check1, ShouldEqual, "one") 108 | So(check2, ShouldEqual, "two") 109 | }) 110 | 111 | Convey("HealthCheck() returns empty string when the check is missing", func() { 112 | svc3 := service.Service{Name: "svc3"} 113 | check, args := multi.HealthCheck(&svc3) 114 | 115 | So(check, ShouldEqual, "") 116 | So(args, ShouldEqual, "") 117 | }) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /discovery/docker_discovery_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/NinesStack/sidecar/service" 9 | "github.com/fsouza/go-dockerclient" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | var hostname = "shakespeare" 14 | 15 | // Define a stubDockerClient that we can use to test the discovery 16 | type stubDockerClient struct { 17 | ErrorOnInspectContainer bool 18 | ErrorOnPing bool 19 | PingChan chan struct{} 20 | } 21 | 22 | func (s *stubDockerClient) InspectContainer(id string) (*docker.Container, error) { 23 | if s.ErrorOnInspectContainer { 24 | return nil, errors.New("Oh no!") 25 | } 26 | 27 | // If we match this ID, return a real setup 28 | if id == "deadbeef1231" { // svcId1 29 | return &docker.Container{ 30 | ID: "deadbeef1231", 31 | Config: &docker.Config{ 32 | Labels: map[string]string{ 33 | "HealthCheck": "HttpGet", 34 | "HealthCheckArgs": "service1 check arguments", 35 | "ServicePort_80": "10000", 36 | "SidecarListener": "10000", 37 | }, 38 | }, 39 | }, nil 40 | } 41 | 42 | // Otherwise return an empty one 43 | return &docker.Container{ 44 | Config: &docker.Config{ 45 | Labels: map[string]string{}, 46 | }, 47 | }, nil 48 | } 49 | 50 | func (s *stubDockerClient) ListContainers(opts docker.ListContainersOptions) ([]docker.APIContainers, error) { 51 | return nil, nil 52 | } 53 | 54 | func (s *stubDockerClient) AddEventListener(listener chan<- *docker.APIEvents) error { 55 | return nil 56 | } 57 | 58 | func (s *stubDockerClient) RemoveEventListener(listener chan *docker.APIEvents) error { 59 | return nil 60 | } 61 | 62 | func (s *stubDockerClient) Ping() error { 63 | if s.ErrorOnPing { 64 | return errors.New("dummy errror") 65 | } 66 | 67 | s.PingChan <- struct{}{} 68 | 69 | return nil 70 | } 71 | 72 | type dummyLooper struct{} 73 | 74 | // Loop will block for enough time to prevent the event loop in DockerDiscovery.Run() 75 | // from closing connQuitChan before the tests finish running 76 | func (*dummyLooper) Loop(fn func() error) { time.Sleep(1 * time.Second) } 77 | func (*dummyLooper) Wait() error { return nil } 78 | func (*dummyLooper) Done(error) {} 79 | func (*dummyLooper) Quit() {} 80 | 81 | func Test_DockerDiscovery(t *testing.T) { 82 | 83 | Convey("Working with Docker containers", t, func() { 84 | endpoint := "http://example.com:2375" 85 | svcId1 := "deadbeef1231" 86 | svcId2 := "deadbeef1011" 87 | ip := "127.0.0.1" 88 | baseTime := time.Now().UTC().Round(time.Second) 89 | service1 := service.Service{ 90 | Name: "beowulf", 91 | ID: svcId1, Hostname: hostname, Updated: baseTime, 92 | Ports: []service.Port{{Port: 80, IP: "127.0.0.1", ServicePort: 10000, Type: "tcp"}}, 93 | } 94 | service2 := service.Service{ID: svcId2, Hostname: hostname, Updated: baseTime} 95 | services := []*service.Service{&service1, &service2} 96 | 97 | client := stubDockerClient{ 98 | ErrorOnInspectContainer: false, 99 | } 100 | 101 | stubClientProvider := func() (DockerClient, error) { 102 | return &client, nil 103 | } 104 | 105 | svcNamer := &RegexpNamer{ServiceNameMatch: "^/(.+)(-[0-9a-z]{7,14})$"} 106 | 107 | disco := NewDockerDiscovery(endpoint, svcNamer, ip) 108 | disco.ClientProvider = stubClientProvider 109 | 110 | Convey("New() configures an endpoint and events channel", func() { 111 | So(disco.endpoint, ShouldEqual, endpoint) 112 | So(disco.events, ShouldNotBeNil) 113 | }) 114 | 115 | Convey("New() sets the advertiseIp", func() { 116 | So(disco.advertiseIp, ShouldEqual, ip) 117 | }) 118 | 119 | Convey("Services() returns the right list of services", func() { 120 | disco.services = services 121 | 122 | processed := disco.Services() 123 | So(processed[0].Format(), ShouldEqual, service1.Format()) 124 | So(processed[1].Format(), ShouldEqual, service2.Format()) 125 | }) 126 | 127 | Convey("Listeners() returns the right list of services", func() { 128 | disco.services = services 129 | 130 | processed := disco.Listeners() 131 | So(len(processed), ShouldEqual, 1) 132 | So(processed[0], ShouldResemble, 133 | ChangeListener{ 134 | Name: "Service(beowulf-deadbeef1231)", 135 | Url: "http://127.0.0.1:80/sidecar/update", 136 | }, 137 | ) 138 | }) 139 | 140 | Convey("handleEvents() prunes dead containers", func() { 141 | disco.services = services 142 | disco.handleEvent(docker.APIEvents{ID: svcId1, Status: "die"}) 143 | 144 | result := disco.Services() 145 | So(len(result), ShouldEqual, 1) 146 | So(result[0].Format(), ShouldEqual, service2.Format()) 147 | }) 148 | 149 | Convey("HealthCheck()", func() { 150 | Convey("returns a valid health check when it's defined", func() { 151 | check, args := disco.HealthCheck(&service1) 152 | So(check, ShouldEqual, "HttpGet") 153 | So(args, ShouldEqual, "service1 check arguments") 154 | }) 155 | 156 | Convey("returns and empty health check when undefined", func() { 157 | check, args := disco.HealthCheck(&service2) 158 | So(check, ShouldEqual, "") 159 | So(args, ShouldEqual, "") 160 | }) 161 | 162 | Convey("handles errors from the Docker client", func() { 163 | disco.ClientProvider = func() (DockerClient, error) { 164 | return &stubDockerClient{ 165 | ErrorOnInspectContainer: true, 166 | }, nil 167 | } 168 | 169 | check, args := disco.HealthCheck(&service2) 170 | So(check, ShouldEqual, "") 171 | So(args, ShouldEqual, "") 172 | }) 173 | }) 174 | 175 | Convey("inspectContainer()", func() { 176 | Convey("looks in the cache first", func() { 177 | disco.containerCache.Set(&service1, &docker.Container{Path: "cached"}) 178 | container, err := disco.inspectContainer(&service1) 179 | 180 | So(err, ShouldBeNil) 181 | So(container.Path, ShouldEqual, "cached") 182 | }) 183 | 184 | Convey("queries Docker if the service isn't cached", func() { 185 | container, err := disco.inspectContainer(&service1) 186 | 187 | So(err, ShouldBeNil) 188 | So(container.Config.Labels["HealthCheck"], ShouldEqual, "HttpGet") 189 | }) 190 | 191 | Convey("bubbles up errors from the Docker client", func() { 192 | disco.ClientProvider = func() (DockerClient, error) { 193 | return &stubDockerClient{ 194 | ErrorOnInspectContainer: true, 195 | }, nil 196 | } 197 | 198 | container, err := disco.inspectContainer(&service1) 199 | So(err, ShouldNotBeNil) 200 | So(container, ShouldBeNil) 201 | }) 202 | }) 203 | 204 | Convey("pruneContainerCache()", func() { 205 | Convey("prunes the containers we no longer see", func() { 206 | liveContainers := make(map[string]interface{}, 1) 207 | liveContainers[svcId1] = true 208 | 209 | // Cache some things 210 | disco.containerCache.Set(&service1, &docker.Container{Path: "cached"}) 211 | disco.containerCache.Set(&service2, &docker.Container{Path: "cached"}) 212 | 213 | So(disco.containerCache.Len(), ShouldEqual, 2) 214 | 215 | disco.containerCache.Prune(liveContainers) 216 | 217 | container := disco.containerCache.Get(svcId2) // Should be missing 218 | So(container, ShouldBeNil) 219 | }) 220 | }) 221 | 222 | Convey("Run()", func() { 223 | disco.sleepInterval = 1 * time.Millisecond 224 | 225 | Convey("pings Docker", func() { 226 | disco.Run(&dummyLooper{}) 227 | 228 | // Check a few times that it tries to ping Docker 229 | for i := 0; i < 3; i++ { 230 | pinged := false 231 | select { 232 | case <-client.PingChan: 233 | pinged = true 234 | case <-time.After(10 * time.Millisecond): 235 | } 236 | 237 | So(pinged, ShouldBeFalse) 238 | } 239 | }) 240 | 241 | Convey("reconnects if the connection is dropped", func() { 242 | connectEvent := make(chan struct{}) 243 | disco.ClientProvider = func() (DockerClient, error) { 244 | connectEvent <- struct{}{} 245 | return stubClientProvider() 246 | } 247 | 248 | client.ErrorOnPing = true 249 | disco.Run(&dummyLooper{}) 250 | 251 | // Check a few times that it tries to reconnect to Docker 252 | for i := 0; i < 3; i++ { 253 | triedToConnect := false 254 | select { 255 | case <-connectEvent: 256 | triedToConnect = true 257 | case <-time.After(10 * time.Millisecond): 258 | } 259 | 260 | So(triedToConnect, ShouldBeTrue) 261 | } 262 | }) 263 | }) 264 | }) 265 | } 266 | -------------------------------------------------------------------------------- /discovery/fixtures/bad-fixture/token: -------------------------------------------------------------------------------- 1 | this would be a token 2 | -------------------------------------------------------------------------------- /discovery/fixtures/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPDCCAiQCCQCKqd63pT0THjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJn 3 | YjEPMA0GA1UECAwGTG9uZG9uMQ8wDQYDVQQHDAZMb25kb24xFjAUBgNVBAoMDUNv 4 | bW11bml0eS5jb20xFjAUBgNVBAMMDWNvbW11bml0eS5jb20wIBcNMjIxMTIyMTEy 5 | MzQ1WhgPMzAyMjAzMjUxMTIzNDVaMF8xCzAJBgNVBAYTAmdiMQ8wDQYDVQQIDAZM 6 | b25kb24xDzANBgNVBAcMBkxvbmRvbjEWMBQGA1UECgwNQ29tbXVuaXR5LmNvbTEW 7 | MBQGA1UEAwwNY29tbXVuaXR5LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC 8 | AQoCggEBAKO3y1q/ELzfZ4vaLptkxy45RNLSgGwE63J6kXSjfsyGLoOg4ZHaqbWX 9 | y/7EsLeHKI1kzj9qV0ygaexbTgAfTKBQZoXxHgMuGMCQRy/SSlNHwG4W7IkJ+bFf 10 | hja2KDleY26ROq1vbTODu50408Mm50c5ynU05Qcu/jDGcHkM6dkb93T7aKzWLAgf 11 | aKxtAnYuHq2MvI+y3E0Qeqo78aaoRYw6L2WSn6xGQoKjStdCb1vRb5xX8Ed8kUDf 12 | cWY6EhsA3dUb+5vBEIhwnzkojJyeCteG//mWrd52ntMoFooEKvcXrFZj70kVJgYo 13 | V7yDNv6SraIxfXIDlsyY+w8wtGDmNX0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA 14 | L4UyZ2IwGq0HFhephmEHc+3NX2tpEIyjKaDdd2VnDKDNgJfXRjzaV4PxrPzlsN3H 15 | unrpRGLzjIy+PVUjhKOpoMzZDdGAXSu6Zpi4wlZ8PdFJbD+CM7WEQGJNytbO6nkW 16 | r5RvlWGFrR52DB5XqyVTwfGr4ugI0C0H0dyfJOVn67m6gqZnntJ9dAHCFPbRKFxo 17 | JrO2Rdx1ZimxVKkK+Gs7bqjmx1Mj4GBm/NOGVwdMBDGWb57dFXnh93xxFnruQ1QR 18 | lXu6dYfcmewOyLDc5WmFXzsGzhSDjPmHi7BjpTbRaQ4h32J0Rv9/9R3mivC7nflt 19 | lztQHTUAk1+on3ARvoERWQ== 20 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /discovery/fixtures/token: -------------------------------------------------------------------------------- 1 | this would be a token 2 | -------------------------------------------------------------------------------- /discovery/kubernetes_api_discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "encoding/json" 5 | "sync" 6 | "time" 7 | 8 | "github.com/NinesStack/sidecar/service" 9 | "github.com/relistan/go-director" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var DefaultK8sLoopInterval = 5 * time.Second 14 | 15 | // A K8sAPIDiscoverer is a discovery mechanism that assumes that a K8s cluster 16 | // with be fronted by a load balancer and that all the ports exposed will match 17 | // up on both the load balancer and the backing pods. It relies on an underlying 18 | // command to run the discovery. This is normally `kubectl`. 19 | type K8sAPIDiscoverer struct { 20 | Namespace string 21 | 22 | Command K8sDiscoveryAdapter 23 | 24 | discoveredSvcs map[string]*K8sService 25 | discoveredNodes *K8sNodes 26 | discoveredPods map[string][]*K8sPod 27 | lock sync.RWMutex 28 | hostname string 29 | loopInterval time.Duration 30 | } 31 | 32 | // NewK8sAPIDiscoverer returns a properly configured K8sAPIDiscoverer 33 | func NewK8sAPIDiscoverer(kubeHost string, kubePort int, namespace string, timeout time.Duration, 34 | credsPath string, hostname string) *K8sAPIDiscoverer { 35 | 36 | cmd := NewKubeAPIDiscoveryCommand(kubeHost, kubePort, namespace, timeout, credsPath) 37 | 38 | return &K8sAPIDiscoverer{ 39 | discoveredSvcs: make(map[string]*K8sService), 40 | discoveredPods: make(map[string][]*K8sPod), 41 | discoveredNodes: &K8sNodes{}, 42 | Namespace: namespace, 43 | Command: cmd, 44 | hostname: hostname, 45 | loopInterval: DefaultK8sLoopInterval, 46 | } 47 | } 48 | 49 | // servicesForNode will emit all the services that we previously discovered. 50 | // This means we will attempt to hit the NodePort for each of the nodes when 51 | // looking for this service over HTTP/TCP. 52 | func (k *K8sAPIDiscoverer) servicesForNode(hostname, ip string) []service.Service { 53 | var services []service.Service 54 | 55 | for svcName, podList := range k.discoveredPods { 56 | for _, pod := range podList { 57 | // We require an annotation called 'ServiceName' to make sure this is 58 | // a service we want to announce. 59 | if len(pod.ServiceName()) < 1 { 60 | continue 61 | } 62 | 63 | // If we don't have a service from K8s, then there are no ports to expose 64 | if k.discoveredPods[pod.ServiceName()] == nil { 65 | continue 66 | } 67 | 68 | if pod.Spec.NodeName != hostname { 69 | continue 70 | } 71 | 72 | svc := k.serviceFromPod(svcName, ip, *pod) 73 | 74 | services = append(services, svc) 75 | } 76 | } 77 | 78 | return services 79 | } 80 | 81 | // serviceFromPod returns a Sidecar service for a K8sPod 82 | func (k *K8sAPIDiscoverer) serviceFromPod(svcName, ip string, pod K8sPod) service.Service { 83 | svc := service.Service{ 84 | ID: pod.Metadata.UID, 85 | Name: svcName, 86 | Image: pod.Image(), 87 | Created: pod.Metadata.CreationTimestamp, 88 | Hostname: pod.Spec.NodeName, 89 | Status: service.ALIVE, 90 | Updated: time.Now().UTC(), 91 | } 92 | 93 | // It's possible to override the default with a ProxyMode label 94 | switch pod.Metadata.Labels.ProxyMode { 95 | case "tcp": 96 | svc.ProxyMode = "tcp" 97 | case "ws": 98 | svc.ProxyMode = "ws" 99 | default: 100 | svc.ProxyMode = "http" 101 | } 102 | 103 | if discovered, ok := k.discoveredSvcs[pod.ServiceName()]; ok { 104 | if discovered.Spec.Ports != nil { 105 | for _, port := range discovered.Spec.Ports { 106 | // We only support entries with NodePort defined 107 | if port.NodePort < 1 { 108 | continue 109 | } 110 | svc.Ports = append(svc.Ports, service.Port{ 111 | Type: "tcp", 112 | Port: int64(port.NodePort), 113 | ServicePort: int64(port.Port), 114 | IP: ip, 115 | }) 116 | } 117 | } 118 | } 119 | return svc 120 | } 121 | 122 | // Services implements part of the Discoverer interface and looks at the last 123 | // cached data from the Command and returns services in a format that Sidecar 124 | // can manage. 125 | func (k *K8sAPIDiscoverer) Services() []service.Service { 126 | k.lock.RLock() 127 | defer k.lock.RUnlock() 128 | 129 | // Enumerate all the K8s nodes we discovered, and for each one, enumerate 130 | // all the services. 131 | var services []service.Service 132 | for _, node := range k.discoveredNodes.Items { 133 | hostname, ip := getIPHostForNode(&node) 134 | 135 | // Don't discover all nodes, only this one. Short circuit if we found it 136 | // since we'll only be in the list once. 137 | if hostname == k.hostname { 138 | services = k.servicesForNode(hostname, ip) 139 | break 140 | } 141 | } 142 | 143 | return services 144 | } 145 | 146 | func getIPHostForNode(node *K8sNode) (hostname string, ip string) { 147 | for _, address := range node.Status.Addresses { 148 | if address.Type == "InternalIP" { 149 | ip = address.Address 150 | } 151 | 152 | if address.Type == "Hostname" { 153 | hostname = address.Address 154 | } 155 | } 156 | 157 | return hostname, ip 158 | } 159 | 160 | // HealthCheck implements part of the Discoverer interface and returns the 161 | // built-in AlwaysSuccessful check, on the assumption that the underlying load 162 | // balancer we are pointing to will have already health checked the service. 163 | func (k *K8sAPIDiscoverer) HealthCheck(svc *service.Service) (string, string) { 164 | return "AlwaysSuccessful", "" 165 | } 166 | 167 | // Listeners implements part of the Discoverer interface and always returns 168 | // an empty list because it doesn't make sense in this context. 169 | func (k *K8sAPIDiscoverer) Listeners() []ChangeListener { 170 | return []ChangeListener{} 171 | } 172 | 173 | // Run is part of the Discoverer interface and calls the Command in a loop, 174 | // which is injected as a Looper. 175 | func (k *K8sAPIDiscoverer) Run(looper director.Looper) { 176 | var ( 177 | data []byte 178 | err error 179 | ) 180 | looper.Loop(func() error { 181 | var wg sync.WaitGroup 182 | 183 | wg.Add(1) 184 | go func() { 185 | data, err := k.getServices() 186 | if err != nil { 187 | log.Errorf("Failed to unmarshal services json: %s, %s", err, string(data)) 188 | } 189 | wg.Done() 190 | }() 191 | 192 | wg.Add(1) 193 | go func() { 194 | data, err = k.getNodes() 195 | if err != nil { 196 | log.Errorf("Failed to unmarshal nodes json: %s, %s", err, string(data)) 197 | } 198 | wg.Done() 199 | }() 200 | 201 | wg.Add(1) 202 | go func() { 203 | data, err = k.getPods() 204 | if err != nil { 205 | log.Errorf("Failed to unmarshal pods json: %s, %s", err, string(data)) 206 | } 207 | wg.Done() 208 | }() 209 | 210 | wg.Add(1) 211 | go func() { 212 | time.Sleep(k.loopInterval) 213 | wg.Done() 214 | }() 215 | 216 | wg.Wait() 217 | return nil 218 | }) 219 | } 220 | 221 | func (k *K8sAPIDiscoverer) getServices() ([]byte, error) { 222 | data, err := k.Command.GetServices() 223 | if err != nil { 224 | log.Errorf("Failed to invoke K8s API discovery: %s", err) 225 | } 226 | 227 | var svcs K8sServices 228 | 229 | err = json.Unmarshal(data, &svcs) 230 | if err != nil { 231 | return data, err 232 | } 233 | 234 | discoveredSvcs := make(map[string]*K8sService) 235 | 236 | for _, s := range svcs.Items { 237 | // Avoid Go loop var pointer re-use 238 | svc := s 239 | discoveredSvcs[svc.ServiceName()] = &svc 240 | } 241 | 242 | k.lock.Lock() 243 | k.discoveredSvcs = discoveredSvcs 244 | k.lock.Unlock() 245 | 246 | return data, err 247 | } 248 | 249 | func (k *K8sAPIDiscoverer) getNodes() ([]byte, error) { 250 | data, err := k.Command.GetNodes() 251 | if err != nil { 252 | log.Errorf("Failed to invoke K8s API discovery: %s", err) 253 | } 254 | 255 | k.lock.Lock() 256 | err = json.Unmarshal(data, &k.discoveredNodes) 257 | k.lock.Unlock() 258 | return data, err 259 | } 260 | 261 | func (k *K8sAPIDiscoverer) getPods() ([]byte, error) { 262 | data, err := k.Command.GetPods() 263 | if err != nil { 264 | log.Errorf("Failed to invoke K8s API discovery: %s", err) 265 | } 266 | 267 | var pods K8sPods 268 | 269 | err = json.Unmarshal(data, &pods) 270 | if err != nil { 271 | return data, err 272 | } 273 | 274 | discoveredPods := make(map[string][]*K8sPod) 275 | 276 | for _, pod := range pods.Items { 277 | // Avoid Go for loop pointer gotcha (will be fixed in Go 1.22) 278 | thisPod := pod 279 | 280 | // We only care about things with a ServiceName defined 281 | if len(pod.ServiceName()) < 1 { 282 | log.Debugf("Skipping pod %s: missing ServiceName label", pod.Metadata.UID) 283 | continue 284 | } 285 | 286 | // Enables explicit skipping of some services via a Label 287 | if pod.Metadata.Labels.SidecarDiscover == "false" { 288 | continue 289 | } 290 | 291 | discoveredPods[pod.ServiceName()] = append(discoveredPods[pod.ServiceName()], &thisPod) 292 | 293 | } 294 | 295 | k.lock.Lock() 296 | k.discoveredPods = discoveredPods 297 | k.lock.Unlock() 298 | return data, err 299 | } 300 | -------------------------------------------------------------------------------- /discovery/kubernetes_support_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/jarcoal/httpmock" 12 | log "github.com/sirupsen/logrus" 13 | . "github.com/smartystreets/goconvey/convey" 14 | ) 15 | 16 | func Test_NewKubeAPIDiscoveryCommand(t *testing.T) { 17 | Convey("NewKubeAPIDiscoveryCommand()", t, func() { 18 | 19 | Convey("returns a properly configured struct", func() { 20 | cmd := NewKubeAPIDiscoveryCommand("beowulf.example.com", 443, "namespace", 10*time.Millisecond, credsPath) 21 | 22 | So(cmd, ShouldNotBeNil) 23 | So(cmd.Namespace, ShouldEqual, "namespace") 24 | So(cmd.Timeout, ShouldEqual, 10*time.Millisecond) 25 | So(cmd.KubeHost, ShouldEqual, "beowulf.example.com") 26 | So(cmd.KubePort, ShouldEqual, 443) 27 | So(cmd.token, ShouldContainSubstring, "this would be a token") 28 | So(cmd.client, ShouldNotBeNil) 29 | }) 30 | 31 | Convey("logs when it can't read the token", func() { 32 | var cmd *KubeAPIDiscoveryCommand 33 | 34 | capture := LogCapture(func() { 35 | cmd = NewKubeAPIDiscoveryCommand("beowulf.example.com", 443, "namespace", 10*time.Millisecond, "/tmp/does-not-exist") 36 | }) 37 | 38 | So(cmd, ShouldBeNil) 39 | So(capture, ShouldContainSubstring, "Failed to read serviceaccount token") 40 | }) 41 | 42 | Convey("logs when it can't read the CA.crt", func() { 43 | var cmd *KubeAPIDiscoveryCommand 44 | 45 | capture := LogCapture(func() { 46 | cmd = NewKubeAPIDiscoveryCommand("beowulf.example.com", 443, "namespace", 10*time.Millisecond, credsPath+"/bad-fixture") 47 | }) 48 | 49 | So(cmd, ShouldNotBeNil) 50 | So(capture, ShouldContainSubstring, "No certs appended!") 51 | 52 | So(cmd.Namespace, ShouldEqual, "namespace") 53 | So(cmd.Timeout, ShouldEqual, 10*time.Millisecond) 54 | So(cmd.KubeHost, ShouldEqual, "beowulf.example.com") 55 | So(cmd.KubePort, ShouldEqual, 443) 56 | So(cmd.token, ShouldContainSubstring, "this would be a token") 57 | So(cmd.client, ShouldNotBeNil) 58 | }) 59 | }) 60 | } 61 | 62 | func Test_makeRequest(t *testing.T) { 63 | Convey("makeRequest()", t, func() { 64 | Reset(func() { httpmock.DeactivateAndReset() }) 65 | 66 | cmd := NewKubeAPIDiscoveryCommand("beowulf.example.com", 80, "namespace", 10*time.Millisecond, credsPath) 67 | httpmock.ActivateNonDefault(cmd.client) 68 | 69 | Convey("makes a request with the right headers and auth", func() { 70 | var auth string 71 | httpmock.RegisterResponder("GET", "http://beowulf.example.com:80/nowhere", 72 | func(req *http.Request) (*http.Response, error) { 73 | auth = req.Header.Get("Authorization") 74 | return httpmock.NewJsonResponse(200, map[string]interface{}{"success": "yeah"}) 75 | }, 76 | ) 77 | 78 | body, err := cmd.makeRequest("/nowhere", "") 79 | So(err, ShouldBeNil) 80 | So(auth, ShouldStartWith, "Bearer ") 81 | So(auth, ShouldContainSubstring, "this would be a token") 82 | 83 | So(body, ShouldNotBeEmpty) 84 | }) 85 | 86 | Convey("handles non-200 status code", func() { 87 | var auth string 88 | httpmock.RegisterResponder("GET", "http://beowulf.example.com:80/nowhere", 89 | func(req *http.Request) (*http.Response, error) { 90 | auth = req.Header.Get("Authorization") 91 | return httpmock.NewJsonResponse(403, map[string]interface{}{"bad": "times"}) 92 | }, 93 | ) 94 | 95 | body, err := cmd.makeRequest("/nowhere", "") 96 | So(err, ShouldNotBeNil) 97 | So(auth, ShouldStartWith, "Bearer ") 98 | So(auth, ShouldContainSubstring, "this would be a token") 99 | 100 | So(err.Error(), ShouldContainSubstring, "got unexpected response code from /nowhere: 403") 101 | So(body, ShouldBeEmpty) 102 | }) 103 | 104 | Convey("handles error back from http call", func() { 105 | httpmock.RegisterResponder("GET", "http://beowulf.example.com:80/nowhere", 106 | httpmock.NewErrorResponder(errors.New("intentional test error")), 107 | ) 108 | 109 | body, err := cmd.makeRequest("/nowhere", "") 110 | 111 | So(err, ShouldNotBeNil) 112 | So(err.Error(), ShouldContainSubstring, "intentional test error") 113 | So(body, ShouldBeEmpty) 114 | }) 115 | }) 116 | } 117 | 118 | func Test_GetServices(t *testing.T) { 119 | Convey("GetServices()", t, func() { 120 | Reset(func() { httpmock.DeactivateAndReset() }) 121 | 122 | cmd := NewKubeAPIDiscoveryCommand("beowulf.example.com", 80, "namespace", 10*time.Millisecond, credsPath) 123 | httpmock.ActivateNonDefault(cmd.client) 124 | 125 | Convey("makes a request with the right headers and auth", func() { 126 | var auth string 127 | httpmock.RegisterResponder("GET", "http://beowulf.example.com:80/api/v1/services/", 128 | func(req *http.Request) (*http.Response, error) { 129 | auth = req.Header.Get("Authorization") 130 | return httpmock.NewJsonResponse(200, map[string]interface{}{"success": "yeah"}) 131 | }, 132 | ) 133 | 134 | body, err := cmd.GetServices() 135 | So(err, ShouldBeNil) 136 | So(auth, ShouldStartWith, "Bearer ") 137 | So(auth, ShouldContainSubstring, "this would be a token") 138 | 139 | So(body, ShouldNotBeEmpty) 140 | }) 141 | }) 142 | } 143 | 144 | func Test_GetNodes(t *testing.T) { 145 | Convey("GetNodes()", t, func() { 146 | Reset(func() { httpmock.DeactivateAndReset() }) 147 | 148 | cmd := NewKubeAPIDiscoveryCommand("beowulf.example.com", 80, "namespace", 10*time.Millisecond, credsPath) 149 | httpmock.ActivateNonDefault(cmd.client) 150 | 151 | Convey("makes a request with the right headers and auth", func() { 152 | var auth string 153 | httpmock.RegisterResponder("GET", "http://beowulf.example.com:80/api/v1/nodes/", 154 | func(req *http.Request) (*http.Response, error) { 155 | auth = req.Header.Get("Authorization") 156 | return httpmock.NewJsonResponse(200, map[string]interface{}{"success": "yeah"}) 157 | }, 158 | ) 159 | 160 | body, err := cmd.GetNodes() 161 | So(err, ShouldBeNil) 162 | So(auth, ShouldStartWith, "Bearer ") 163 | So(auth, ShouldContainSubstring, "this would be a token") 164 | 165 | So(body, ShouldNotBeEmpty) 166 | }) 167 | }) 168 | } 169 | 170 | // LogCapture logs for async testing where we can't get a nice handle on thigns 171 | func LogCapture(fn func()) string { 172 | capture := &bytes.Buffer{} 173 | log.SetOutput(capture) 174 | fn() 175 | log.SetOutput(os.Stdout) 176 | 177 | return capture.String() 178 | } 179 | -------------------------------------------------------------------------------- /discovery/service_namer.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/fsouza/go-dockerclient" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type ServiceNamer interface { 12 | ServiceName(*docker.APIContainers) string 13 | } 14 | 15 | // A ServiceNamer that uses a regex to match against the service name 16 | // or else uses the image as the service name. 17 | type RegexpNamer struct { 18 | ServiceNameMatch string 19 | expression *regexp.Regexp 20 | } 21 | 22 | func NewRegexpNamer(exprStr string) (*RegexpNamer, error) { 23 | var err error 24 | namer := &RegexpNamer{ServiceNameMatch: exprStr} 25 | namer.expression, err = regexp.Compile(exprStr) 26 | if err != nil { 27 | return nil, fmt.Errorf("Invalid regex, can't compile: %s", exprStr) 28 | } 29 | 30 | return namer, nil 31 | } 32 | 33 | // Return a properly regex-matched name for the service, or failing that, 34 | // the Image ID which we use to stand in for the name of the service. 35 | func (r *RegexpNamer) ServiceName(container *docker.APIContainers) string { 36 | if container == nil { 37 | log.Warn("ServiceName() called with nil service passed!") 38 | return "" 39 | } 40 | 41 | if r.expression == nil { 42 | log.Errorf("Invalid regex can't match using: %s", r.ServiceNameMatch) 43 | return container.Image 44 | } 45 | 46 | var svcName string 47 | 48 | toMatch := []byte(container.Names[0]) 49 | matches := r.expression.FindSubmatch(toMatch) 50 | if len(matches) < 1 { 51 | svcName = container.Image 52 | } else { 53 | svcName = string(matches[1]) 54 | } 55 | 56 | return svcName 57 | } 58 | 59 | // A ServiceNamer that uses a name provided in a Docker label as the name 60 | // for the service. 61 | type DockerLabelNamer struct { 62 | Label string 63 | } 64 | 65 | // Return the value of the configured Docker label, or default to the image 66 | // name. 67 | func (d *DockerLabelNamer) ServiceName(container *docker.APIContainers) string { 68 | if container == nil { 69 | log.Warn("ServiceName() called with nil service passed!") 70 | return "" 71 | } 72 | 73 | for label, value := range container.Labels { 74 | if label == d.Label { 75 | return value 76 | } 77 | } 78 | 79 | log.Debugf( 80 | "Found container with no '%s' label: %s (%s), returning '%s'", d.Label, 81 | container.ID, container.Names[0], container.Image, 82 | ) 83 | 84 | return container.Image 85 | } 86 | -------------------------------------------------------------------------------- /discovery/service_namer_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/fsouza/go-dockerclient" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func Test_RegexpNamer(t *testing.T) { 11 | Convey("RegexpNamer", t, func() { 12 | container := &docker.APIContainers{ 13 | ID: "deadbeef001", 14 | Image: "gonitro/awesome-svc:0.1.34", 15 | Names: []string{"/awesome-svc-1231b1b12323"}, 16 | Labels: map[string]string{}, 17 | } 18 | 19 | var namer ServiceNamer 20 | var err error 21 | 22 | Convey("Extracts a ServiceName", func() { 23 | namer, err = NewRegexpNamer("^/(.+)(-[0-9a-z]{7,14})$") 24 | So(err, ShouldBeNil) 25 | So(namer.ServiceName(container), ShouldEqual, "awesome-svc") 26 | }) 27 | 28 | Convey("Returns the image when the expression doesn't match", func() { 29 | namer, err = NewRegexpNamer("ASDF") 30 | So(namer.ServiceName(container), ShouldEqual, "gonitro/awesome-svc:0.1.34") 31 | }) 32 | 33 | Convey("Handles error when passed a nil service", func() { 34 | namer = &RegexpNamer{} 35 | So(namer.ServiceName(nil), ShouldEqual, "") 36 | }) 37 | }) 38 | } 39 | 40 | func Test_DockerLabelNamer(t *testing.T) { 41 | Convey("DockerLabelNamer", t, func() { 42 | container := &docker.APIContainers{ 43 | ID: "deadbeef001", 44 | Image: "gonitro/awesome-svc:0.1.34", 45 | Names: []string{"/awesome-svc-1231b1b12323"}, 46 | Labels: map[string]string{"ServiceName": "awesome-svc-1"}, 47 | } 48 | 49 | var namer ServiceNamer 50 | 51 | Convey("Extracts a ServiceName", func() { 52 | namer = &DockerLabelNamer{Label: "ServiceName"} 53 | So(namer.ServiceName(container), ShouldEqual, "awesome-svc-1") 54 | }) 55 | 56 | Convey("Returns the image when the expression doesn't match", func() { 57 | namer = &DockerLabelNamer{Label: "ASDF"} 58 | So(namer.ServiceName(container), ShouldEqual, "gonitro/awesome-svc:0.1.34") 59 | }) 60 | 61 | Convey("Handles error when passed a nil service", func() { 62 | namer = &DockerLabelNamer{} 63 | So(namer.ServiceName(nil), ShouldEqual, "") 64 | }) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /discovery/static_discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "time" 11 | 12 | "github.com/relistan/go-director" 13 | log "github.com/sirupsen/logrus" 14 | 15 | "github.com/NinesStack/sidecar/service" 16 | ) 17 | 18 | type Target struct { 19 | Service service.Service 20 | Check StaticCheck 21 | ListenPort int64 22 | } 23 | 24 | // A StaticDiscovery is an instance of a configuration file based discovery 25 | // mechanism. It is read on startup and never again, currently, so there 26 | // is no need for any locking or synchronization mechanism. 27 | type StaticDiscovery struct { 28 | Targets []*Target 29 | ConfigFile string 30 | Hostname string 31 | DefaultIP string 32 | } 33 | 34 | type StaticCheck struct { 35 | Type string 36 | Args string 37 | } 38 | 39 | func NewStaticDiscovery(filename string, defaultIP string) *StaticDiscovery { 40 | hostname, err := os.Hostname() 41 | if err != nil { 42 | log.Errorf("Error getting hostname! %s", err.Error()) 43 | } 44 | return &StaticDiscovery{ 45 | ConfigFile: filename, 46 | Hostname: hostname, 47 | DefaultIP: defaultIP, 48 | } 49 | } 50 | 51 | func (d *StaticDiscovery) HealthCheck(svc *service.Service) (string, string) { 52 | for _, target := range d.Targets { 53 | if svc.ID == target.Service.ID { 54 | return target.Check.Type, target.Check.Args 55 | } 56 | } 57 | return "", "" 58 | } 59 | 60 | // Returns the list of services derived from the targets that were parsed 61 | // out of the config file. 62 | func (d *StaticDiscovery) Services() []service.Service { 63 | var services []service.Service 64 | for _, target := range d.Targets { 65 | target.Service.Updated = time.Now().UTC() 66 | services = append(services, target.Service) 67 | } 68 | return services 69 | } 70 | 71 | // Listeners returns the list of services configured to be ChangeEvent listeners 72 | func (d *StaticDiscovery) Listeners() []ChangeListener { 73 | var listeners []ChangeListener 74 | for _, target := range d.Targets { 75 | if target.ListenPort > 0 { 76 | listener := ChangeListener{ 77 | Name: target.Service.ListenerName(), 78 | Url: fmt.Sprintf("http://%s:%d/sidecar/update", d.Hostname, target.ListenPort), 79 | } 80 | listeners = append(listeners, listener) 81 | } 82 | } 83 | return listeners 84 | } 85 | 86 | // Causes the configuration to be parsed and loaded. There is no background 87 | // processing needed on an ongoing basis. 88 | func (d *StaticDiscovery) Run(looper director.Looper) { 89 | var err error 90 | 91 | d.Targets, err = d.ParseConfig(d.ConfigFile) 92 | if err != nil { 93 | log.Errorf("StaticDiscovery cannot parse: %s", err.Error()) 94 | looper.Done(nil) 95 | } 96 | } 97 | 98 | // Parses a JSON config file containing an array of Targets. These are 99 | // then augmented with a random hex ID and stamped with the current 100 | // UTC time as the creation time. The same hex ID is applied to the Check 101 | // and the Service to make sure that they are matched by the healthy 102 | // package later on. 103 | func (d *StaticDiscovery) ParseConfig(filename string) ([]*Target, error) { 104 | file, err := ioutil.ReadFile(filename) 105 | if err != nil { 106 | log.Errorf("Unable to read announcements file: '%s!'", err.Error()) 107 | return nil, err 108 | } 109 | 110 | var targets []*Target 111 | err = json.Unmarshal(file, &targets) 112 | if err != nil { 113 | return nil, fmt.Errorf("Unable to unmarshal Target: %s", err) 114 | } 115 | 116 | // Have to loop with traditional 'for' loop so we can modify entries 117 | for _, target := range targets { 118 | idBytes, err := RandomHex(6) 119 | if err != nil { 120 | log.Errorf("ParseConfig(): Unable to get random bytes (%s)", err.Error()) 121 | return nil, err 122 | } 123 | 124 | target.Service.ID = string(idBytes) 125 | target.Service.Created = time.Now().UTC() 126 | // We _can_ export services for a 3rd party. If we don't specify 127 | // the hostname, then it's for this host. 128 | if target.Service.Hostname == "" { 129 | target.Service.Hostname = d.Hostname 130 | } 131 | 132 | // Make sure we have an IP address on ports 133 | for i, port := range target.Service.Ports { 134 | if len(port.IP) == 0 { 135 | target.Service.Ports[i].IP = d.DefaultIP 136 | } 137 | } 138 | 139 | log.Printf("Discovered service: %s, ID: %s", 140 | target.Service.Name, 141 | target.Service.ID, 142 | ) 143 | } 144 | return targets, nil 145 | } 146 | 147 | // Return a defined number of random bytes as a slice 148 | func RandomHex(count int) ([]byte, error) { 149 | raw := make([]byte, count) 150 | _, err := rand.Read(raw) 151 | if err != nil { 152 | log.Errorf("RandomBytes(): Error %s", err.Error()) 153 | return nil, err 154 | } 155 | 156 | encoded := make([]byte, count*2) 157 | hex.Encode(encoded, raw) 158 | return encoded, nil 159 | } 160 | -------------------------------------------------------------------------------- /discovery/static_discovery_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/NinesStack/sidecar/service" 8 | "github.com/relistan/go-director" 9 | . "github.com/smartystreets/goconvey/convey" 10 | ) 11 | 12 | const ( 13 | STATIC_JSON = "../fixtures/static.json" 14 | STATIC_HOSTNAMED_JSON = "../fixtures/static-hostnamed.json" 15 | ) 16 | 17 | func Test_ParseConfig(t *testing.T) { 18 | Convey("ParseConfig()", t, func() { 19 | ip := "127.0.0.1" 20 | disco := NewStaticDiscovery(STATIC_JSON, ip) 21 | disco.Hostname = hostname 22 | 23 | Convey("Errors when there is a problem with the file", func() { 24 | _, err := disco.ParseConfig("!!!!") 25 | So(err, ShouldNotBeNil) 26 | }) 27 | 28 | Convey("Returns a properly parsed list of Targets", func() { 29 | parsed, err := disco.ParseConfig(STATIC_JSON) 30 | So(err, ShouldBeNil) 31 | So(len(parsed), ShouldEqual, 1) 32 | So(parsed[0].Service.Ports[0].Type, ShouldEqual, "tcp") 33 | }) 34 | 35 | Convey("Applies hostnames to services", func() { 36 | parsed, err := disco.ParseConfig(STATIC_JSON) 37 | So(err, ShouldBeNil) 38 | So(len(parsed), ShouldEqual, 1) 39 | So(parsed[0].Service.Hostname, ShouldEqual, hostname) 40 | }) 41 | 42 | Convey("Uses the given hostname when specified", func() { 43 | parsed, _ := disco.ParseConfig(STATIC_HOSTNAMED_JSON) 44 | So(len(parsed), ShouldEqual, 1) 45 | So(parsed[0].Service.Hostname, ShouldEqual, "chaucer") 46 | }) 47 | 48 | Convey("Assigns the default IP address when a port doesn't have one", func() { 49 | parsed, _ := disco.ParseConfig(STATIC_JSON) 50 | So(len(parsed), ShouldEqual, 1) 51 | So(parsed[0].Service.Ports[0].IP, ShouldEqual, ip) 52 | }) 53 | }) 54 | } 55 | 56 | func Test_Services(t *testing.T) { 57 | Convey("Services()", t, func() { 58 | ip := "127.0.0.1" 59 | disco := NewStaticDiscovery(STATIC_JSON, ip) 60 | tgt1 := &Target{ 61 | Service: service.Service{ID: "asdf"}, 62 | } 63 | tgt2 := &Target{ 64 | Service: service.Service{ID: "foofoo"}, 65 | } 66 | disco.Targets = []*Target{tgt1, tgt2} 67 | 68 | Convey("Returns a list of services extracted from Targets", func() { 69 | services := disco.Services() 70 | 71 | So(len(services), ShouldEqual, 2) 72 | So(services[0], ShouldResemble, tgt1.Service) 73 | So(services[1], ShouldResemble, tgt2.Service) 74 | }) 75 | 76 | Convey("Updates the current timestamp each time", func() { 77 | s := disco.Services() 78 | firstUpdate := s[0].Updated 79 | time.Sleep(1 * time.Millisecond) 80 | s = disco.Services() 81 | secondUpdate := s[0].Updated 82 | 83 | So(firstUpdate.Before(secondUpdate), ShouldBeTrue) 84 | }) 85 | }) 86 | } 87 | 88 | func Test_Listeners(t *testing.T) { 89 | Convey("Listeners()", t, func() { 90 | ip := "127.0.0.1" 91 | disco := NewStaticDiscovery(STATIC_JSON, ip) 92 | 93 | Convey("Loads targets from the config", func() { 94 | disco.Run(director.NewFreeLooper(director.ONCE, nil)) 95 | So(len(disco.Targets), ShouldEqual, 1) 96 | }) 97 | 98 | Convey("Returns all listeners extracted from Targets", func() { 99 | tgt1 := &Target{ 100 | Service: service.Service{Name: "beowulf", ID: "asdf"}, 101 | ListenPort: 10000, 102 | } 103 | tgt2 := &Target{ 104 | Service: service.Service{Name: "hrothgar", ID: "abba"}, 105 | ListenPort: 11000, 106 | } 107 | disco.Targets = []*Target{tgt1, tgt2} 108 | 109 | listeners := disco.Listeners() 110 | 111 | expected0 := ChangeListener{ 112 | Name: "Service(beowulf-asdf)", 113 | Url: "http://" + disco.Hostname + ":10000/sidecar/update", 114 | } 115 | expected1 := ChangeListener{ 116 | Name: "Service(hrothgar-abba)", 117 | Url: "http://" + disco.Hostname + ":11000/sidecar/update", 118 | } 119 | 120 | So(len(listeners), ShouldEqual, 2) 121 | So(listeners[0], ShouldResemble, expected0) 122 | So(listeners[1], ShouldResemble, expected1) 123 | }) 124 | }) 125 | } 126 | 127 | func Test_Run(t *testing.T) { 128 | Convey("Run()", t, func() { 129 | ip := "127.0.0.1" 130 | disco := NewStaticDiscovery(STATIC_JSON, ip) 131 | looper := director.NewFreeLooper(1, make(chan error)) 132 | 133 | Convey("Parses the specified config file", func() { 134 | So(len(disco.Targets), ShouldEqual, 0) 135 | disco.Run(looper) 136 | So(len(disco.Targets), ShouldEqual, 1) 137 | }) 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM library/alpine:3.20 2 | 3 | # Necessary depedencies 4 | RUN apk --update add haproxy bash curl tar s6 5 | 6 | # Set up Sidecar 7 | ADD sidecar /sidecar/sidecar 8 | ADD views /sidecar/views 9 | ADD docker/s6 /etc 10 | ADD ui /sidecar/ui 11 | 12 | EXPOSE 7777 13 | 14 | CMD ["/bin/s6-svscan", "/etc/s6"] 15 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | Docker Support 2 | ============== 3 | 4 | This directory contains the basics to build and start a Docker container that 5 | runs Sidecar. To work properly the container assumes that it is run in host 6 | networking mode. The command line arguments required to run it on are in the 7 | `run` script in this directory: 8 | 9 | ```bash 10 | IMAGE=$1 11 | echo "Starting from $IMAGE" 12 | docker run -i -t -v /var/run/docker.sock:/var/run/docker.sock \ 13 | --label SidecarDiscover=false \ 14 | -e SIDECAR_SEEDS="127.0.0.1" \ 15 | --net=host \ 16 | --cap-add NET_ADMIN $IMAGE 17 | ``` 18 | 19 | **Volume Mount:** Requires volume mounting the Docker socket. If you choose 20 | not to do this, then you'll need to pass in `DOCKER_*` environment variables to 21 | configure access to the Docker daemon. If you do that, you can use those 22 | settings instead of the Sidecar `DOCKER_URL` env var. 23 | 24 | **Label:** This prevents Sidecar from discovering itself, which, when it 25 | happens, is a pretty useless discovery. 26 | 27 | **Networking:** Requires host-based networking to work. Various pitfalls lie 28 | in not doing this, including difficulty of all the containers of finding 29 | HAproxy, and also inability for Sidecar to be reachable from outside without 30 | additional port mappings. 31 | 32 | **Capabilities:** We add the `NET_ADMIN` capability to the container to allow it 33 | to bind a IP address for HAproxy when the container starts. This is *optional* 34 | and only required if you are using a specific IP address for the HAproxy 35 | binding. 36 | 37 | **Environment Variables:** This tells the container to look to itself as 38 | the seed. You'll want to set this to one or more IP addresses or hostnames 39 | of the cluster seed hosts. 40 | 41 | As mentioned above, the default configuration is all set up with the 42 | expectation that you will map `/var/run/docker.sock` into the container. This 43 | is where Docker usually writes its Unix socket. If you want to use TCP to 44 | connect, you'll need to do some more work and pass in `DOCKER_*` environment 45 | variables to configure access to it from Sidecar. 46 | 47 | Sidecar logs in `info` mode by default. You can switch this to one of: `error`, 48 | `warn`, `debug` using the `SIDECAR_LOGGING_LEVEL` environment variable. 49 | 50 | The default cluster name is `default` but can be changed with the appropriately 51 | named `SIDECAR_CLUSTER_NAME` env var. 52 | 53 | How It Works 54 | ------------ 55 | 56 | This is an overview of how the networking works when Sidecar is running in a 57 | container. The following diagram depicts a service running on the Docker host making a request to an upstream service while leveraging Sidecar for service discovery: 58 | 59 | ![Sidecar Networking](../views/static/sidecar-networking.png) 60 | 61 | 1. The service running in Service Container A makes a request to External 62 | Service. This request leaves the `eth0` interface, the container's default 63 | route, and traverses the Docker bridged network. 64 | 2. The request enters the host's network namespace and the Sidecar Container 65 | via the docker0 bridge interface. The request is bound for 192.168.168.168 66 | which is bound to the loopback interface, so it is forwarded there. 67 | 3. HAproxy answers the request on 192.168.168.168 and it is bound for a 68 | recognized port with live backends. The request is then forwarded out to 69 | External Service via the host's default route, its `eth0`. 70 | 4. The request leaves the host, with the host's public address on the `eth0` 71 | interface as the source. The service running as External Service receives the 72 | request directly from HAproxy on the Docker host. 73 | 74 | Testing 75 | ------- 76 | 77 | A good way to test out if your system is working, is to deploy the official 78 | Nginx container and see if it shows up in discovery. The following command line 79 | will do that: 80 | 81 | ``` 82 | $ docker run --label HealthCheck=HttpGet \ # Specify HttpGet health check type 83 | --label ServicePort_80=8080 \ # Map 8080 to exposed port 80 84 | --label ServicePort_443=8443 \ # Map 8443 to exposed port 443 85 | --label HealthCheckArgs="http://{{ host }}:{{ tcp 8080 }}/" \ # Health check this URL 86 | -d -P nginx # Detach and map ports 87 | ``` 88 | 89 | For regular testing, a script called `run-services` in this directory will start 90 | up three instances of the `nginx` container with the appropriate labels and 91 | dynamically bound ports. This is useful if you have a Sidecar installation up and 92 | want to see a service show up in HAproxy. 93 | -------------------------------------------------------------------------------- /docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | die() { 4 | echo $1 5 | exit 1 6 | } 7 | 8 | file ./sidecar | grep "ELF.*LSB" || die "../sidecar is missing or not a Linux binary" 9 | echo "Building..." 10 | npm install --prefix ./ui 11 | docker build -f ./docker/Dockerfile -t sidecar . || die "Failed to build" 12 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | # Docker for Mac dev support for Sidecar containers 4 | 5 | # This compose file is intended for use on Docker for Mac 6 | # which behaves quite differently from normal Docker engine 7 | # with regard to port mappings and connectivity to containers. 8 | # It will _not_ do what you want on other Docker installs. 9 | 10 | # Requirements: You must set up an IP alias to lo0 on your 11 | # Mac with the address 192.168.168.167/32. This is accomplished 12 | # with: 13 | # sudo ifconfig lo0 alias 192.168.168.167/32 14 | 15 | services: 16 | sidecar: 17 | # We have to hack the hostname so we can expose the right 18 | # address to HAproxy as configured by Sidecar. 19 | hostname: osx 20 | image: gonitro/sidecar:e905262 21 | cap_add: 22 | - NET_ADMIN 23 | labels: 24 | SidecarDiscover: "false" 25 | environment: 26 | - BIND_IP=192.168.168.168 27 | - SIDECAR_LOGGING_LEVEL=info 28 | - SIDECAR_SEEDS=127.0.0.1 29 | - SIDECAR_CLUSTER_NAME=development 30 | - "SIDECAR_DISCOVERY=docker static" 31 | volumes: 32 | - /var/run/docker.sock:/var/run/docker.sock 33 | - /tmp/static.json:/sidecar/static.json 34 | ports: 35 | - 10000-10050:10000-10050 36 | - 1777:7777 37 | # This combined with setting the hostname above allows us 38 | # to connect to the Mac itself in order to talk to other 39 | # services. 40 | extra_hosts: 41 | - "osx:192.168.168.167" 42 | 43 | # A sample service that shows how you would set up 44 | # a service to talk to the configured container above. 45 | nginx1: 46 | image: "nginx:latest" 47 | hostname: nginx1 48 | labels: 49 | HealthCheck: HttpGet 50 | HealthCheckArgs: "http://osx:{{ tcp 10001 }}/" 51 | ServiceName: nginx 52 | ServicePort_80: "10001" 53 | ports: 54 | - "80" 55 | - "443" 56 | extra_hosts: 57 | - "osx:192.168.168.167" 58 | -------------------------------------------------------------------------------- /docker/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Simple wrapper to docker to start the named image with the correct options 4 | 5 | IMAGE=$1 6 | echo "Starting from $IMAGE" 7 | docker run -i -t -v /var/run/docker.sock:/var/run/docker.sock --label SidecarDiscover=false -e SIDECAR_LOGGING_LEVEL=debug -e SIDECAR_SEEDS="127.0.0.1" -e HAPROXY_DISABLE=true --net=host --cap-add NET_ADMIN $IMAGE 8 | -------------------------------------------------------------------------------- /docker/run-services: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Normal services 4 | for i in `seq 1 5`; do 5 | docker run -l 'HealthCheck=HttpGet' -l 'HealthCheckArgs=http://{{ host }}:{{ tcp 9500 }}/' -l 'ServiceName=nginx' -l 'ServicePort_80=9500' -P -d -t nginx 6 | done 7 | 8 | # Sidecar event subscriber 9 | docker run -l 'HealthCheck=HttpGet' -l 'HealthCheckArgs=http://{{ host }}:{{ tcp 9500 }}/' -l 'ServiceName=nginx' -l 'ServicePort_80=9500' -l "SidecarListener=9500" -P -d -t nginx 10 | -------------------------------------------------------------------------------- /docker/s6/s6/.s6-svscan/crash: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Container crashed" -------------------------------------------------------------------------------- /docker/s6/s6/.s6-svscan/finish: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Container shutting down" -------------------------------------------------------------------------------- /docker/s6/s6/sidecar.svc/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd /sidecar 4 | 5 | # As a transition from the old config, if there are spaces in 6 | # the env var, add them as CLI options. Commas will be 7 | # handled internally by envconfig. 8 | echo $SIDECAR_SEEDS | grep ' ' >/dev/null 9 | if [[ $? -eq 0 ]]; then 10 | cat < /root/.kube/config 39 | apiVersion: v1 40 | clusters: 41 | - cluster: 42 | server: https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT} 43 | name: dev 44 | kind: Config 45 | preferences: {} 46 | users: [] 47 | EOF 48 | fi 49 | 50 | exec ./sidecar $CLI 51 | -------------------------------------------------------------------------------- /envoy/adapter/adapter_test.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/NinesStack/sidecar/service" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func Test_isPortCollision(t *testing.T) { 11 | Convey("isPortCollision()", t, func() { 12 | portsMap := map[int64]string{ 13 | int64(10001): "beowulf", 14 | int64(10002): "grendel", 15 | } 16 | 17 | Convey("returns true when the port is a different service", func() { 18 | svc := &service.Service{Name: "hrothgar"} 19 | port := service.Port{ServicePort: int64(10001)} 20 | 21 | result := isPortCollision(portsMap, svc, port) 22 | 23 | So(result, ShouldBeTrue) 24 | So(portsMap[int64(10001)], ShouldEqual, "beowulf") 25 | }) 26 | 27 | Convey("returns false when the port is the same service", func() { 28 | svc := &service.Service{Name: "beowulf"} 29 | port := service.Port{ServicePort: int64(10001)} 30 | 31 | result := isPortCollision(portsMap, svc, port) 32 | 33 | So(result, ShouldBeFalse) 34 | }) 35 | 36 | Convey("returns false and assigns it when the port is not assigned", func() { 37 | svc := &service.Service{Name: "hrothgar"} 38 | port := service.Port{ServicePort: int64(10003)} 39 | 40 | result := isPortCollision(portsMap, svc, port) 41 | 42 | So(result, ShouldBeFalse) 43 | So(portsMap[int64(10003)], ShouldEqual, "hrothgar") 44 | }) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /envoy/server.go: -------------------------------------------------------------------------------- 1 | package envoy 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/NinesStack/sidecar/catalog" 11 | "github.com/NinesStack/sidecar/config" 12 | "github.com/NinesStack/sidecar/envoy/adapter" 13 | envoy_disco "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" 14 | "github.com/envoyproxy/go-control-plane/pkg/cache/v3" 15 | xds "github.com/envoyproxy/go-control-plane/pkg/server/v3" 16 | "github.com/relistan/go-director" 17 | log "github.com/sirupsen/logrus" 18 | "google.golang.org/grpc" 19 | ) 20 | 21 | const ( 22 | // LooperUpdateInterval indicates how often to check if the state has changed 23 | LooperUpdateInterval = 1 * time.Second 24 | ) 25 | 26 | type xdsCallbacks struct{} 27 | 28 | func (*xdsCallbacks) OnStreamOpen(context.Context, int64, string) error { return nil } 29 | func (*xdsCallbacks) OnStreamClosed(int64) {} 30 | func (*xdsCallbacks) OnStreamRequest(int64, *envoy_disco.DiscoveryRequest) error { return nil } 31 | func (*xdsCallbacks) OnStreamResponse(ctx context.Context, _ int64, req *envoy_disco.DiscoveryRequest, _ *envoy_disco.DiscoveryResponse) { 32 | if req.GetErrorDetail().GetCode() != 0 { 33 | log.Errorf("Received Envoy error code %d: %s", 34 | req.GetErrorDetail().GetCode(), 35 | strings.ReplaceAll(req.GetErrorDetail().GetMessage(), "\n", ""), 36 | ) 37 | } 38 | } 39 | func (*xdsCallbacks) OnFetchRequest(context.Context, *envoy_disco.DiscoveryRequest) error { return nil } 40 | func (*xdsCallbacks) OnFetchResponse(*envoy_disco.DiscoveryRequest, *envoy_disco.DiscoveryResponse) {} 41 | func (*xdsCallbacks) OnDeltaStreamOpen(ctx context.Context, streamID int64, typeURL string) error { 42 | return nil 43 | } 44 | func (*xdsCallbacks) OnStreamDeltaRequest(streamID int64, req *envoy_disco.DeltaDiscoveryRequest) error { 45 | return nil 46 | } 47 | func (*xdsCallbacks) OnStreamDeltaResponse(streamID int64, 48 | req *envoy_disco.DeltaDiscoveryRequest, resp *envoy_disco.DeltaDiscoveryResponse) { 49 | } 50 | func (*xdsCallbacks) OnDeltaStreamClosed(streamID int64) {} 51 | 52 | // Server is a wrapper around Envoy's control plane xDS gRPC server and it uses 53 | // the Aggregated Discovery Service (ADS) mechanism. 54 | type Server struct { 55 | config config.EnvoyConfig 56 | state *catalog.ServicesState 57 | snapshotCache cache.SnapshotCache 58 | xdsServer xds.Server 59 | } 60 | 61 | // newSnapshotVersion returns a unique version for Envoy cache snapshots 62 | func newSnapshotVersion() string { 63 | // When triggering watches after a cache snapshot is set, the go-control-plane 64 | // only sends resources which have a different version to Envoy. 65 | // `time.Now().UnixNano()` should always return a unique number. 66 | return strconv.FormatInt(time.Now().UnixNano(), 10) 67 | } 68 | 69 | // Run starts the Envoy update looper and the Envoy gRPC server 70 | func (s *Server) Run(ctx context.Context, looper director.Looper, grpcListener net.Listener) { 71 | // The local hostname needs to match the value passed via `--service-node` to Envoy 72 | // See https://github.com/envoyproxy/envoy/issues/144#issuecomment-267401271 73 | // This never changes, so we don't need to lock the state here 74 | hostname := s.state.Hostname 75 | 76 | // prevStateLastChanged caches the state.LastChanged timestamp when we send an 77 | // update to Envoy 78 | prevStateLastChanged := time.Unix(0, 0) 79 | go looper.Loop(func() error { 80 | s.state.RLock() 81 | lastChanged := s.state.LastChanged 82 | 83 | // Do nothing if the state hasn't changed 84 | if lastChanged == prevStateLastChanged { 85 | s.state.RUnlock() 86 | return nil 87 | } 88 | resources := adapter.EnvoyResourcesFromState(s.state, s.config.BindIP, s.config.UseHostnames) 89 | s.state.RUnlock() 90 | 91 | prevStateLastChanged = lastChanged 92 | 93 | // Set the computed listeners and clusters in the current snapshot to 94 | // send them to Envoy. 95 | // See the eventual consistency considerations in the documentation for 96 | // details about how Envoy updates these resources: 97 | // https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol#eventual-consistency-considerations 98 | 99 | // Create a new snapshot version and send the listeners and clusters to Envoy 100 | snapshotVersion := newSnapshotVersion() 101 | snap, err := cache.NewSnapshot(snapshotVersion, resources.AsMap()) 102 | if err != nil { 103 | log.Errorf("Failed to create new Envoy cache snapshot: %s", err) 104 | return nil 105 | } 106 | 107 | err = s.snapshotCache.SetSnapshot(ctx, hostname, snap) 108 | if err != nil { 109 | log.Errorf("Failed to set new Envoy cache snapshot: %s", err) 110 | return nil 111 | } 112 | 113 | log.Infof("Sent %d endpoints, %d listeners and %d clusters to Envoy with version %s", 114 | len(resources.Endpoints), len(resources.Listeners), len(resources.Clusters), snapshotVersion, 115 | ) 116 | 117 | return nil 118 | }) 119 | 120 | grpcServer := grpc.NewServer() 121 | envoy_disco.RegisterAggregatedDiscoveryServiceServer(grpcServer, s.xdsServer) 122 | 123 | go func() { 124 | if err := grpcServer.Serve(grpcListener); err != nil { 125 | log.Fatalf("Failed to start Envoy gRPC server: %s", err) 126 | } 127 | }() 128 | 129 | // Currently, this will block forever 130 | <-ctx.Done() 131 | grpcServer.GracefulStop() 132 | } 133 | 134 | // NewServer creates a new Server instance 135 | func NewServer(ctx context.Context, state *catalog.ServicesState, config config.EnvoyConfig) *Server { 136 | logger := log.New() 137 | if lvl, err := log.ParseLevel(config.LoggingLevel); err == nil { 138 | logger.SetLevel(lvl) 139 | } else { 140 | log.Warnf("Invalid Envoy logging level (%s): %s", config.LoggingLevel, err) 141 | } 142 | 143 | snapshotCache := cache.NewSnapshotCache(true, cache.IDHash{}, logger) 144 | 145 | return &Server{ 146 | config: config, 147 | state: state, 148 | snapshotCache: snapshotCache, 149 | xdsServer: xds.NewServer(ctx, snapshotCache, &xdsCallbacks{}), 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /fixtures/static-hostnamed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Service": { 4 | "Name": "some_service", 5 | "Image": "bb6268ff91dc42a51f51db53846f72102ed9ff3f", 6 | "Hostname": "chaucer", 7 | "Ports": [ 8 | { 9 | "Type": "tcp", 10 | "Port": 10234, 11 | "ServicePort": 9999 12 | } 13 | ], 14 | "ProxyMode": "http" 15 | }, 16 | "Check": { 17 | "Type": "HttpGet", 18 | "Args": "http://:10234/" 19 | } 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /fixtures/static.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Service": { 4 | "Name": "some_service", 5 | "Image": "bb6268ff91dc42a51f51db53846f72102ed9ff3f", 6 | "Ports": [ 7 | { 8 | "Type": "tcp", 9 | "Port": 10234, 10 | "ServicePort": 9999 11 | } 12 | ], 13 | "ProxyMode": "http" 14 | }, 15 | "ListenPort": 9999, 16 | "Check": { 17 | "Type": "HttpGet", 18 | "Args": "http://:10234/" 19 | } 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/NinesStack/sidecar 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/NinesStack/memberlist v0.0.0-20170522194404-cfac2b5cf519 7 | github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 8 | github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect 9 | github.com/envoyproxy/go-control-plane v0.10.3 10 | github.com/fsouza/go-dockerclient v1.3.1 11 | github.com/gogo/protobuf v1.2.1 // indirect 12 | github.com/golang/protobuf v1.5.2 13 | github.com/gorilla/mux v1.6.2 14 | github.com/hashicorp/go-cleanhttp v0.5.0 15 | github.com/jarcoal/httpmock v1.2.0 16 | github.com/kelseyhightower/envconfig v1.3.0 17 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 18 | github.com/pquerna/ffjson v0.0.0-20171002144729-d49c2bc1aa13 19 | github.com/relistan/go-director v0.0.0-20181104164737-5f56787d9731 20 | github.com/sirupsen/logrus v1.0.6 21 | github.com/smartystreets/assertions v1.2.0 // indirect 22 | github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff 23 | google.golang.org/grpc v1.45.0 24 | google.golang.org/protobuf v1.28.0 25 | gopkg.in/alecthomas/kingpin.v2 v2.2.5 26 | gopkg.in/jarcoal/httpmock.v1 v1.0.0-20170412085702-cf52904a3cf0 27 | gopkg.in/relistan/rubberneck.v1 v1.0.1 28 | ) 29 | 30 | require ( 31 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect 32 | github.com/Microsoft/go-winio v0.4.11 // indirect 33 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 34 | github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 // indirect 35 | github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 // indirect 36 | github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 // indirect 37 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect 38 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect 39 | github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect 40 | github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc // indirect 41 | github.com/docker/docker v0.7.3-0.20180827131323-0c5f8d2b9b23 // indirect 42 | github.com/docker/go-connections v0.4.0 // indirect 43 | github.com/docker/go-units v0.3.3 // indirect 44 | github.com/docker/libnetwork v0.8.0-dev.2.0.20180608203834-19279f049241 // indirect 45 | github.com/envoyproxy/protoc-gen-validate v0.6.7 // indirect 46 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect 47 | github.com/gorilla/context v1.1.1 // indirect 48 | github.com/hashicorp/errwrap v1.0.0 // indirect 49 | github.com/hashicorp/go-immutable-radix v1.0.0 // indirect 50 | github.com/hashicorp/go-msgpack v0.5.5 // indirect 51 | github.com/hashicorp/go-multierror v1.0.0 // indirect 52 | github.com/hashicorp/go-sockaddr v1.0.0 // indirect 53 | github.com/hashicorp/go-uuid v1.0.1 // indirect 54 | github.com/hashicorp/golang-lru v0.5.1 // indirect 55 | github.com/jtolds/gls v4.20.0+incompatible // indirect 56 | github.com/mattn/go-isatty v0.0.3 // indirect 57 | github.com/miekg/dns v1.0.14 // indirect 58 | github.com/onsi/gomega v1.4.2 // indirect 59 | github.com/opencontainers/go-digest v1.0.0-rc1 // indirect 60 | github.com/opencontainers/image-spec v1.0.1 // indirect 61 | github.com/opencontainers/runc v0.1.1 // indirect 62 | github.com/pkg/errors v0.8.1 // indirect 63 | github.com/relistan/rubberneck v1.1.0 // indirect 64 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect 65 | github.com/sergi/go-diff v1.0.0 // indirect 66 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect 67 | golang.org/x/net v0.2.0 // indirect 68 | golang.org/x/sync v0.1.0 // indirect 69 | golang.org/x/sys v0.2.0 // indirect 70 | golang.org/x/text v0.4.0 // indirect 71 | google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7 // indirect 72 | gotest.tools v2.2.0+incompatible // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /healthy/commands.go: -------------------------------------------------------------------------------- 1 | // These are types that conform to the Checker interface 2 | // and can be assigned to a Check for watching service 3 | // health. 4 | package healthy 5 | 6 | import ( 7 | "errors" 8 | "net/http" 9 | "os/exec" 10 | "strings" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // A Checker that makes an HTTP get call and expects to get 16 | // a 200-299 back as success. Anything else is considered 17 | // a failure. The URL to hit is passed as the args to the 18 | // Run method. 19 | type HttpGetCmd struct{} 20 | 21 | func (h *HttpGetCmd) Run(args string) (int, error) { 22 | resp, err := http.Get(args) 23 | if resp == nil { 24 | return UNKNOWN, errors.New("No body from HTTP response!") 25 | } 26 | defer resp.Body.Close() 27 | 28 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 29 | return HEALTHY, nil 30 | } 31 | 32 | return SICKLY, err 33 | } 34 | 35 | // A Checker that works with Nagios checks or other simple 36 | // external tools. It expects a 0 exit code from the command 37 | // that was run. Anything else is considered to be SICKLY. 38 | // The command is passed as the args to the Run method. The 39 | // command will be executed without a shell wrapper to keep 40 | // the call as lean as possible in the majority case. If you 41 | // need a shell you must invoke it yourself. 42 | type ExternalCmd struct{} 43 | 44 | func (e *ExternalCmd) Run(args string) (int, error) { 45 | cliArgs := strings.Split(args, " ") 46 | cmd := exec.Command(cliArgs[0], cliArgs[1:]...) 47 | 48 | output, err := cmd.CombinedOutput() 49 | if err == nil { 50 | return HEALTHY, nil 51 | } 52 | 53 | log.Errorf("Error running command: %s (%s)\n", err.Error(), output) 54 | return SICKLY, err 55 | } 56 | 57 | // A Checker that always returns success. Usually used in 58 | // cases where a service can't actually be health checked for 59 | // some reason. 60 | type AlwaysSuccessfulCmd struct{} 61 | 62 | func (a *AlwaysSuccessfulCmd) Run(args string) (int, error) { 63 | return HEALTHY, nil 64 | } 65 | -------------------------------------------------------------------------------- /healthy/healthy.go: -------------------------------------------------------------------------------- 1 | // A lightweight health-checking module so we can make 2 | // sure that services are running and healthy before 3 | // we announce them to our peers. Has a standard check 4 | // interval for all checks, not configurable per check. 5 | 6 | package healthy 7 | 8 | import ( 9 | "errors" 10 | "sync" 11 | "time" 12 | 13 | "github.com/NinesStack/sidecar/service" 14 | "github.com/relistan/go-director" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const ( 19 | HEALTHY = 0 20 | SICKLY = iota 21 | FAILED = iota 22 | UNKNOWN = iota 23 | ) 24 | 25 | const ( 26 | FOREVER = -1 27 | WATCH_INTERVAL = 500 * time.Millisecond 28 | HEALTH_INTERVAL = 3 * time.Second 29 | ) 30 | 31 | // The Monitor is responsible for managing and running Checks. 32 | // It has a fixed check interval that is used for all checks. 33 | // Access must be synchronized so direct access to struct 34 | // members is possible but requires use of the RWMutex. 35 | type Monitor struct { 36 | Checks map[string]*Check 37 | CheckInterval time.Duration 38 | DefaultCheckHost string 39 | DiscoveryFn func() []service.Service 40 | DefaultCheckEndpoint string 41 | sync.RWMutex 42 | } 43 | 44 | // A Check defines some information about how to talk to the 45 | // service to determine health. Each Check has a Command that 46 | // is used to actually do the work. The command is invoked each 47 | // interval and passed the arguments stored in the Check. The 48 | // default Check type is an HttpGetCmd and the Args must be 49 | // the URL to pass to the check. 50 | type Check struct { 51 | // The ID of this check 52 | ID string 53 | 54 | // The most recent status of this check 55 | Status int 56 | 57 | // The number of runs it has been in failed state 58 | Count int 59 | 60 | // The maximum number before we declare that it failed 61 | MaxCount int 62 | 63 | // String describing the kind of check 64 | Type string 65 | 66 | // The arguments to pass to the Checker 67 | Args string 68 | 69 | // The Checker to run to validate this 70 | Command Checker 71 | 72 | // The last recorded error on this check 73 | LastError error 74 | } 75 | 76 | type Checker interface { 77 | Run(args string) (int, error) 78 | } 79 | 80 | // NewCheck returns a properly configured default Check 81 | func NewCheck(id string) *Check { 82 | check := Check{ 83 | ID: id, 84 | Count: 0, 85 | Type: "http", 86 | Command: &HttpGetCmd{}, 87 | MaxCount: 1, 88 | Status: UNKNOWN, 89 | } 90 | return &check 91 | } 92 | 93 | // UpdateStatus take the status integer and error and applies them to the status 94 | // of the current Check. 95 | func (check *Check) UpdateStatus(status int, err error) { 96 | if err != nil { 97 | log.Debugf("Error executing check, status UNKNOWN: (id %s)", check.ID) 98 | check.Status = UNKNOWN 99 | check.LastError = err 100 | } else { 101 | check.Status = status 102 | } 103 | 104 | if status == HEALTHY { 105 | check.Count = 0 106 | return 107 | } 108 | 109 | check.Count = check.Count + 1 110 | 111 | if check.Count >= check.MaxCount { 112 | check.Status = FAILED 113 | } 114 | } 115 | 116 | func (check *Check) ServiceStatus() int { 117 | switch check.Status { 118 | case HEALTHY: 119 | return service.ALIVE 120 | case SICKLY: 121 | return service.ALIVE 122 | case UNKNOWN: 123 | return service.UNKNOWN 124 | default: 125 | return service.UNHEALTHY 126 | } 127 | } 128 | 129 | // NewMonitor returns a properly configured default configuration of a Monitor. 130 | func NewMonitor(defaultCheckHost string, defaultCheckEndpoint string) *Monitor { 131 | monitor := Monitor{ 132 | Checks: make(map[string]*Check, 5), 133 | CheckInterval: HEALTH_INTERVAL, 134 | DefaultCheckHost: defaultCheckHost, 135 | DefaultCheckEndpoint: defaultCheckEndpoint, 136 | } 137 | return &monitor 138 | } 139 | 140 | // Add a Check to the list. Handles synchronization. 141 | func (m *Monitor) AddCheck(check *Check) { 142 | m.Lock() 143 | defer m.Unlock() 144 | log.Printf("Adding health check: %s (ID: %s), Args: %s", check.Type, check.ID, check.Args) 145 | m.Checks[check.ID] = check 146 | } 147 | 148 | // MarkService takes a service and mark its Status appropriately based on the 149 | // current check we have configured. 150 | func (m *Monitor) MarkService(svc *service.Service) { 151 | // We remove checks when encountering a Tombstone record. This 152 | // prevents us from storing up checks forever. The discovery 153 | // mechanism must create tombstones when services go away, so 154 | // this is the best signal we'll get that a check is no longer 155 | // needed. Assumes we're only health checking _our own_ services. 156 | m.RLock() 157 | if _, ok := m.Checks[svc.ID]; ok { 158 | svc.Status = m.Checks[svc.ID].ServiceStatus() 159 | } else { 160 | svc.Status = service.UNKNOWN 161 | } 162 | m.RUnlock() 163 | } 164 | 165 | // Run runs the main monitoring loop. The looper controls the actual run behavior. 166 | func (m *Monitor) Run(looper director.Looper) { 167 | looper.Loop(func() error { 168 | log.Debugf("Running checks") 169 | 170 | var wg sync.WaitGroup 171 | 172 | // Make immutable copy of m.Checks (checks are still mutable) 173 | m.RLock() 174 | checks := make(map[string]*Check, len(m.Checks)) 175 | for k, v := range m.Checks { 176 | checks[k] = v 177 | } 178 | m.RUnlock() 179 | 180 | wg.Add(len(checks)) 181 | for _, check := range checks { 182 | // Run all checks in parallel in goroutines 183 | resultChan := make(chan checkResult, 1) 184 | 185 | go func(check *Check, resultChan chan checkResult) { 186 | result, err := check.Command.Run(check.Args) 187 | resultChan <- checkResult{result, err} 188 | }(check, resultChan) // copy check pointer for the goroutine 189 | 190 | go func(check *Check, resultChan chan checkResult) { 191 | defer wg.Done() 192 | 193 | // We make the call but we time out if it gets too close to the 194 | // m.CheckInterval. 195 | select { 196 | case result := <-resultChan: 197 | check.UpdateStatus(result.status, result.err) 198 | case <-time.After(m.CheckInterval - 1*time.Millisecond): 199 | log.Errorf("Error, check %s timed out! (%v)", check.ID, check.Args) 200 | check.UpdateStatus(UNKNOWN, errors.New("Timed out!")) 201 | } 202 | }(check, resultChan) // copy check pointer for the goroutine 203 | } 204 | 205 | // Let's make sure we don't continue to spool up 206 | // huge quantities of goroutines. Wait on all of them 207 | // to complete before moving on. This could slow down 208 | // our check loop if something doesn't time out properly. 209 | wg.Wait() 210 | 211 | return nil 212 | }) 213 | } 214 | 215 | type checkResult struct { 216 | status int 217 | err error 218 | } 219 | -------------------------------------------------------------------------------- /healthy/healthy_test.go: -------------------------------------------------------------------------------- 1 | package healthy 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/NinesStack/sidecar/service" 9 | "github.com/relistan/go-director" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func Test_NewCheck(t *testing.T) { 14 | Convey("Returns a properly configured Check", t, func() { 15 | check := NewCheck("testing") 16 | 17 | So(check.Count, ShouldEqual, 0) 18 | So(check.Type, ShouldEqual, "http") 19 | So(check.MaxCount, ShouldEqual, 1) 20 | So(check.ID, ShouldEqual, "testing") 21 | So(check.Command, ShouldResemble, &HttpGetCmd{}) 22 | }) 23 | } 24 | 25 | func Test_NewMonitor(t *testing.T) { 26 | Convey("Returns a properly configured Monitor", t, func() { 27 | monitor := NewMonitor(hostname, "/") 28 | 29 | So(monitor.CheckInterval, ShouldEqual, HEALTH_INTERVAL) 30 | So(len(monitor.Checks), ShouldEqual, 0) 31 | }) 32 | } 33 | 34 | func Test_AddCheck(t *testing.T) { 35 | Convey("Adds a check to the list", t, func() { 36 | monitor := NewMonitor(hostname, "/") 37 | So(len(monitor.Checks), ShouldEqual, 0) 38 | monitor.AddCheck(&Check{ID: "123"}) 39 | So(len(monitor.Checks), ShouldEqual, 1) 40 | monitor.AddCheck(&Check{ID: "234"}) 41 | So(len(monitor.Checks), ShouldEqual, 2) 42 | }) 43 | } 44 | 45 | type mockCommand struct { 46 | CallCount int 47 | LastArgs string 48 | DesiredResult int 49 | Error error 50 | } 51 | 52 | func (m *mockCommand) Run(args string) (int, error) { 53 | m.CallCount = m.CallCount + 1 54 | m.LastArgs = args 55 | return m.DesiredResult, m.Error 56 | } 57 | 58 | type slowCommand struct{} 59 | 60 | func (s *slowCommand) Run(args string) (int, error) { 61 | time.Sleep(10 * time.Millisecond) 62 | return HEALTHY, nil 63 | } 64 | 65 | func Test_RunningChecks(t *testing.T) { 66 | Convey("Working with health checks", t, func() { 67 | monitor := NewMonitor(hostname, "/") 68 | cmd := mockCommand{DesiredResult: HEALTHY} 69 | check := &Check{ 70 | Type: "mock", 71 | Args: "testing", 72 | Command: &cmd, 73 | } 74 | monitor.AddCheck(check) 75 | 76 | looper := director.NewFreeLooper(director.ONCE, nil) 77 | 78 | Convey("The Check Command gets evaluated", func() { 79 | monitor.Run(looper) 80 | So(cmd.CallCount, ShouldEqual, 1) 81 | So(cmd.LastArgs, ShouldEqual, "testing") 82 | So(cmd.DesiredResult, ShouldEqual, HEALTHY) // We know it's our cmd 83 | }) 84 | 85 | Convey("Healthy Checks are marked healthy", func() { 86 | monitor.Run(looper) 87 | So(cmd.CallCount, ShouldEqual, 1) 88 | So(cmd.LastArgs, ShouldEqual, "testing") 89 | So(check.Status, ShouldEqual, HEALTHY) 90 | }) 91 | 92 | Convey("Unhealthy Checks are marked unhealthy", func() { 93 | fail := mockCommand{DesiredResult: SICKLY} 94 | badCheck := &Check{ 95 | Type: "mock", 96 | Args: "testing123", 97 | Command: &fail, 98 | MaxCount: 3, 99 | } 100 | monitor.AddCheck(badCheck) 101 | monitor.Run(looper) 102 | 103 | So(fail.CallCount, ShouldEqual, 1) 104 | So(badCheck.Status, ShouldEqual, SICKLY) 105 | }) 106 | 107 | Convey("Erroring checks are marked UNKNOWN", func() { 108 | fail := mockCommand{Error: errors.New("Uh oh!"), DesiredResult: FAILED} 109 | badCheck := &Check{ 110 | Type: "mock", 111 | Args: "testing123", 112 | Command: &fail, 113 | MaxCount: 3, 114 | } 115 | monitor.AddCheck(badCheck) 116 | monitor.Run(looper) 117 | 118 | So(fail.CallCount, ShouldEqual, 1) 119 | So(badCheck.Status, ShouldEqual, UNKNOWN) 120 | }) 121 | 122 | Convey("Checks that fail too many times are marked FAILED", func() { 123 | fail := mockCommand{DesiredResult: SICKLY} 124 | maxCount := 2 125 | badCheck := &Check{ 126 | Type: "mock", 127 | Args: "testing123", 128 | Command: &fail, 129 | MaxCount: maxCount, 130 | } 131 | monitor.AddCheck(badCheck) 132 | monitor.Run(director.NewFreeLooper(maxCount, nil)) 133 | So(fail.CallCount, ShouldEqual, maxCount) 134 | So(badCheck.Count, ShouldEqual, maxCount) 135 | So(badCheck.Status, ShouldEqual, FAILED) 136 | }) 137 | 138 | Convey("Checks that were failed return to health", func() { 139 | healthy := mockCommand{DesiredResult: HEALTHY} 140 | badCheck := &Check{ 141 | Type: "mock", 142 | Status: FAILED, 143 | Args: "testing123", 144 | Command: &healthy, 145 | Count: 2, 146 | } 147 | monitor.AddCheck(badCheck) 148 | monitor.Run(looper) 149 | So(badCheck.Count, ShouldEqual, 0) 150 | So(badCheck.Status, ShouldEqual, HEALTHY) 151 | 152 | }) 153 | 154 | Convey("Checks that take too long time out", func() { 155 | check := &Check{ 156 | ID: "test", 157 | Type: "mock", 158 | Status: FAILED, 159 | Args: "testing123", 160 | Command: &slowCommand{}, 161 | MaxCount: 3, 162 | } 163 | monitor.AddCheck(check) 164 | monitor.CheckInterval = 1 * time.Millisecond 165 | monitor.Run(looper) 166 | 167 | So(check.Status, ShouldEqual, UNKNOWN) 168 | So(check.LastError.Error(), ShouldEqual, "Timed out!") 169 | }) 170 | 171 | Convey("Checks that had an error become UNKNOWN on first pass", func() { 172 | check := NewCheck("test") 173 | check.Command = &slowCommand{} 174 | check.MaxCount = 3 175 | check.UpdateStatus(1, errors.New("Borked!")) 176 | 177 | So(check.Status, ShouldEqual, UNKNOWN) 178 | }) 179 | }) 180 | } 181 | 182 | func Test_MarkingServices(t *testing.T) { 183 | 184 | Convey("When marking services", t, func() { 185 | // Set up a bunch of services in various states and some checks. 186 | // Then we health check them and look at the results carefully. 187 | monitor := NewMonitor(hostname, "/") 188 | services := []service.Service{ 189 | {ID: "test", Status: service.ALIVE}, 190 | {ID: "bad", Status: service.ALIVE}, 191 | {ID: "unknown", Status: service.ALIVE}, 192 | {ID: "test2", Status: service.TOMBSTONE}, 193 | {ID: "unknown2", Status: service.UNKNOWN}, 194 | } 195 | 196 | looper := director.NewFreeLooper(director.ONCE, nil) 197 | monitor.DiscoveryFn = func() []service.Service { return services } 198 | 199 | monitor.AddCheck( 200 | &Check{ 201 | ID: "test", 202 | Type: "mock", 203 | Status: HEALTHY, 204 | Args: "testing123", 205 | Command: &mockCommand{DesiredResult: HEALTHY}, 206 | }, 207 | ) 208 | monitor.AddCheck( 209 | &Check{ 210 | ID: "bad", 211 | Type: "mock", 212 | Status: HEALTHY, 213 | Args: "testing123", 214 | Command: &mockCommand{DesiredResult: SICKLY}, 215 | }, 216 | ) 217 | monitor.AddCheck( 218 | &Check{ 219 | ID: "test2", 220 | Type: "mock", 221 | Status: HEALTHY, 222 | Args: "foofoofoo", 223 | Command: &mockCommand{DesiredResult: SICKLY}, 224 | }, 225 | ) 226 | monitor.AddCheck( 227 | &Check{ 228 | ID: "unknown2", 229 | Type: "mock", 230 | Status: HEALTHY, 231 | Args: "foofoofoo", 232 | Command: &mockCommand{DesiredResult: HEALTHY}, 233 | }, 234 | ) 235 | 236 | monitor.Run(looper) 237 | 238 | svcList := monitor.Services() 239 | 240 | Convey("When healthy, marks the service as ALIVE", func() { 241 | So(svcList[0].Status, ShouldEqual, service.ALIVE) 242 | }) 243 | 244 | Convey("When not healthy, marks the service as UNHEALTHY", func() { 245 | So(svcList[1].Status, ShouldEqual, service.UNHEALTHY) 246 | }) 247 | 248 | Convey("When there is no check, marks the service as UNKNOWN", func() { 249 | So(svcList[2].Status, ShouldEqual, service.UNKNOWN) 250 | }) 251 | 252 | Convey("Removes a check when encountering a Tombstone", func() { 253 | So(svcList[3].Status, ShouldEqual, service.UNHEALTHY) 254 | }) 255 | 256 | Convey("Transitions services to healthy when they are", func() { 257 | So(svcList[4].Status, ShouldEqual, service.ALIVE) 258 | }) 259 | }) 260 | } 261 | -------------------------------------------------------------------------------- /healthy/service_bridge.go: -------------------------------------------------------------------------------- 1 | package healthy 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/NinesStack/sidecar/discovery" 9 | "github.com/NinesStack/sidecar/service" 10 | "github.com/relistan/go-director" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const ( 15 | DEFAULT_STATUS_ENDPOINT = "/" 16 | ) 17 | 18 | func (m *Monitor) Services() []service.Service { 19 | var svcList []service.Service 20 | 21 | if m.DiscoveryFn == nil { 22 | log.Errorf("Error: DiscoveryFn not defined!") 23 | return []service.Service{} 24 | } 25 | 26 | for _, svc := range m.DiscoveryFn() { 27 | if svc.ID == "" { 28 | log.Errorf("Error: monitor found empty service ID") 29 | continue 30 | } 31 | 32 | m.MarkService(&svc) 33 | svcList = append(svcList, svc) 34 | } 35 | 36 | return svcList 37 | } 38 | 39 | func findFirstTCPPort(svc *service.Service) *service.Port { 40 | for _, port := range svc.Ports { 41 | if port.Type == "tcp" { 42 | return &port 43 | } 44 | } 45 | return nil 46 | } 47 | 48 | // Configure a default check for a service. The default is to return an HTTP 49 | // check on the first TCP port on the endpoint set in DEFAULT_STATUS_ENDPOINT. 50 | func (m *Monitor) defaultCheckForService(svc *service.Service) *Check { 51 | port := findFirstTCPPort(svc) 52 | if port == nil { 53 | return &Check{ID: svc.ID, Command: &AlwaysSuccessfulCmd{}} 54 | } 55 | 56 | // Use the const default unless we've been provided something else 57 | defaultCheckEndpoint := DEFAULT_STATUS_ENDPOINT 58 | if len(m.DefaultCheckEndpoint) != 0 { 59 | defaultCheckEndpoint = m.DefaultCheckEndpoint 60 | } 61 | 62 | url := fmt.Sprintf("http://%v:%v%v", m.DefaultCheckHost, port.Port, defaultCheckEndpoint) 63 | return &Check{ 64 | ID: svc.ID, 65 | Type: "HttpGet", 66 | Args: url, 67 | Status: FAILED, 68 | Command: &HttpGetCmd{}, 69 | } 70 | } 71 | 72 | func (m *Monitor) GetCommandNamed(name string) Checker { 73 | switch name { 74 | case "HttpGet": 75 | return &HttpGetCmd{} 76 | case "External": 77 | return &ExternalCmd{} 78 | case "AlwaysSuccessful": 79 | return &AlwaysSuccessfulCmd{} 80 | default: 81 | return &HttpGetCmd{} 82 | } 83 | } 84 | 85 | // Talks to a Discoverer and returns the configured check 86 | func (m *Monitor) fetchCheckForService(svc *service.Service, disco discovery.Discoverer) *Check { 87 | 88 | check := &Check{} 89 | check.Type, check.Args = disco.HealthCheck(svc) 90 | if check.Type == "" { 91 | log.Warnf("Got empty check type for service %s (id: %s) with args: %s!", svc.Name, svc.ID, check.Args) 92 | return nil 93 | } 94 | 95 | // Setup some other parts of the check that don't come from discovery 96 | check.ID = svc.ID 97 | check.Command = m.GetCommandNamed(check.Type) 98 | check.Status = FAILED 99 | 100 | return check 101 | } 102 | 103 | // Use templating to substitute in some info about the service. Important because 104 | // we won't know the actual Port that the container will bind to, for example. 105 | func (m *Monitor) templateCheckArgs(check *Check, svc *service.Service) string { 106 | funcMap := template.FuncMap{ 107 | "tcp": func(p int64) int64 { return svc.PortForServicePort(p, "tcp") }, 108 | "udp": func(p int64) int64 { return svc.PortForServicePort(p, "udp") }, 109 | "host": func() string { return m.DefaultCheckHost }, 110 | "container": func() string { return svc.Hostname }, 111 | } 112 | 113 | t, err := template.New("check").Funcs(funcMap).Parse(check.Args) 114 | if err != nil { 115 | log.Errorf("Unable to parse check Args: '%s'", check.Args) 116 | return check.Args 117 | } 118 | 119 | var output bytes.Buffer 120 | err = t.Execute(&output, svc) 121 | if err != nil { 122 | log.Errorf("Unable to execute template: '%s'", check.Args) 123 | return check.Args 124 | } 125 | 126 | return output.String() 127 | } 128 | 129 | // CheckForService returns a Check that has been properly configured for this 130 | // particular service. 131 | func (m *Monitor) CheckForService(svc *service.Service, disco discovery.Discoverer) *Check { 132 | check := m.fetchCheckForService(svc, disco) 133 | if check == nil { // We got nothing 134 | log.Warnf("Using default check for service %s (id: %s).", svc.Name, svc.ID) 135 | check = m.defaultCheckForService(svc) 136 | } 137 | 138 | check.Args = m.templateCheckArgs(check, svc) 139 | 140 | return check 141 | } 142 | 143 | // Watch loops over a list of services and adds checks for services we don't already 144 | // know about. It then removes any checks for services which have gone away. All 145 | // services are expected to be local to this node. 146 | func (m *Monitor) Watch(disco discovery.Discoverer, looper director.Looper) { 147 | m.DiscoveryFn = disco.Services // Store this so we can use it from Services() 148 | 149 | looper.Loop(func() error { 150 | services := disco.Services() 151 | 152 | // Add checks when new services are found 153 | for _, svc := range services { 154 | if m.Checks[svc.ID] == nil { 155 | check := m.CheckForService(&svc, disco) 156 | if check.Command == nil { 157 | log.Errorf( 158 | "Attempted to add %s (id: %s) but no check configured!", 159 | svc.Name, svc.ID, 160 | ) 161 | } else { 162 | m.AddCheck(check) 163 | } 164 | } 165 | } 166 | 167 | m.Lock() 168 | defer m.Unlock() 169 | OUTER: 170 | // We remove checks when encountering a missing service. This 171 | // prevents us from storing up checks forever. This is the only 172 | // way we'll find out about a service going away. 173 | for _, check := range m.Checks { 174 | for _, svc := range services { 175 | // Continue if we have a matching service/check pair 176 | if svc.ID == check.ID { 177 | continue OUTER 178 | } 179 | } 180 | 181 | // Remove checks for services that are no longer running 182 | delete(m.Checks, check.ID) 183 | } 184 | 185 | return nil 186 | }) 187 | } 188 | -------------------------------------------------------------------------------- /healthy/service_bridge_test.go: -------------------------------------------------------------------------------- 1 | package healthy 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/NinesStack/sidecar/discovery" 8 | "github.com/NinesStack/sidecar/service" 9 | "github.com/relistan/go-director" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | var hostname string = "indefatigable" 14 | 15 | type mockDiscoverer struct { 16 | listFn func() []service.Service 17 | } 18 | 19 | func (m *mockDiscoverer) Services() []service.Service { 20 | return m.listFn() 21 | } 22 | 23 | func (m *mockDiscoverer) Listeners() []discovery.ChangeListener { 24 | return nil 25 | } 26 | 27 | func (m *mockDiscoverer) HealthCheck(svc *service.Service) (string, string) { 28 | if svc.Name == "hasCheck" { 29 | return "HttpGet", "http://{{ host }}:{{ tcp 8081 }}/status/check" 30 | } 31 | 32 | if svc.Name == "containerCheck" { 33 | return "HttpGet", "http://{{ container }}:{{ tcp 8081 }}/status/check" 34 | } 35 | 36 | return "", "" 37 | } 38 | 39 | func (m *mockDiscoverer) Run(director.Looper) {} 40 | 41 | func Test_ServicesBridge(t *testing.T) { 42 | Convey("The services bridge", t, func() { 43 | svcId1 := "deadbeef123" 44 | svcId2 := "deadbeef101" 45 | svcId3 := "deadbeef102" 46 | svcId4 := "deadbeef103" 47 | baseTime := time.Now().UTC().Round(time.Second) 48 | 49 | service1 := service.Service{ID: svcId1, Hostname: hostname, Updated: baseTime} 50 | service2 := service.Service{ID: svcId2, Hostname: hostname, Updated: baseTime} 51 | service3 := service.Service{ID: svcId3, Hostname: hostname, Updated: baseTime} 52 | service4 := service.Service{ID: svcId4, Hostname: hostname, Updated: baseTime} 53 | empty := service.Service{} 54 | 55 | services := []service.Service{service1, service2, service3, service4, empty} 56 | 57 | monitor := NewMonitor(hostname, "/") 58 | monitor.DiscoveryFn = func() []service.Service { return services } 59 | 60 | check1 := Check{ 61 | ID: svcId1, 62 | Status: HEALTHY, 63 | } 64 | check2 := Check{ 65 | ID: svcId2, 66 | Status: UNKNOWN, 67 | } 68 | check3 := Check{ 69 | ID: svcId3, 70 | Status: SICKLY, 71 | } 72 | check4 := Check{ 73 | ID: svcId4, 74 | Status: FAILED, 75 | } 76 | monitor.AddCheck(&check1) 77 | monitor.AddCheck(&check2) 78 | monitor.AddCheck(&check3) 79 | monitor.AddCheck(&check4) 80 | 81 | Convey("Returns all the services, marked appropriately", func() { 82 | svcList := monitor.Services() 83 | So(len(svcList), ShouldEqual, 4) 84 | }) 85 | 86 | Convey("Returns an empty list when DiscoveryFn is not defined", func() { 87 | monitor.DiscoveryFn = nil 88 | svcList := monitor.Services() 89 | So(len(svcList), ShouldEqual, 0) 90 | }) 91 | 92 | Convey("Returns services that are healthy", func() { 93 | svcList := monitor.Services() 94 | 95 | var found bool 96 | 97 | for _, svc := range svcList { 98 | if svc.ID == svcId1 { 99 | found = true 100 | break 101 | } 102 | } 103 | 104 | So(found, ShouldBeTrue) 105 | }) 106 | 107 | Convey("Returns services that are sickly", func() { 108 | svcList := monitor.Services() 109 | 110 | var found bool 111 | 112 | for _, svc := range svcList { 113 | if svc.ID == svcId3 { 114 | found = true 115 | break 116 | } 117 | } 118 | 119 | So(found, ShouldBeTrue) 120 | }) 121 | 122 | Convey("Returns services that are unknown", func() { 123 | svcList := monitor.Services() 124 | 125 | var found bool 126 | 127 | for _, svc := range svcList { 128 | if svc.ID == svcId2 { 129 | found = true 130 | break 131 | } 132 | } 133 | 134 | So(found, ShouldBeTrue) 135 | }) 136 | 137 | Convey("Responds to changes in a list of services", func() { 138 | So(len(monitor.Checks), ShouldEqual, 4) 139 | 140 | ports := []service.Port{ 141 | {Type: "udp", Port: 11234, ServicePort: 8080, IP: "127.0.0.1"}, 142 | {Type: "tcp", Port: 1234, ServicePort: 8081, IP: "127.0.0.1"}, 143 | } 144 | svc := service.Service{ID: "babbacabba", Name: "testing-12312312", Ports: ports} 145 | svcList := []service.Service{svc} 146 | 147 | disco := &mockDiscoverer{listFn: func() []service.Service { return svcList }} 148 | 149 | cmd := HttpGetCmd{} 150 | check := &Check{ 151 | ID: svc.ID, 152 | Command: &cmd, 153 | Type: "HttpGet", 154 | Args: "http://" + hostname + ":1234/", 155 | Status: FAILED, 156 | } 157 | looper := director.NewTimedLooper(5, 5*time.Nanosecond, nil) 158 | 159 | monitor.Watch(disco, looper) 160 | 161 | So(len(monitor.Checks), ShouldEqual, 1) 162 | So(monitor.Checks[svc.ID], ShouldResemble, check) 163 | }) 164 | }) 165 | } 166 | 167 | func Test_CheckForService(t *testing.T) { 168 | Convey("When building a default check", t, func() { 169 | svcId1 := "deadbeef123" 170 | ports := []service.Port{ 171 | {Type: "udp", Port: 11234, ServicePort: 8080, IP: "127.0.0.1"}, 172 | {Type: "tcp", Port: 1234, ServicePort: 8081, IP: "127.0.0.1"}, 173 | } 174 | service1 := service.Service{ID: svcId1, Hostname: hostname, Ports: ports} 175 | 176 | Convey("Find the first tcp port", func() { 177 | port := findFirstTCPPort(&service1) 178 | So(port, ShouldNotBeNil) 179 | So(port.Port, ShouldEqual, 1234) 180 | So(port.Type, ShouldEqual, "tcp") 181 | }) 182 | 183 | Convey("Returns proper check", func() { 184 | monitor := NewMonitor(hostname, "/") 185 | check := monitor.CheckForService(&service1, &mockDiscoverer{}) 186 | So(check.ID, ShouldEqual, service1.ID) 187 | }) 188 | 189 | Convey("Templates in the check arguments", func() { 190 | monitor := NewMonitor(hostname, "/") 191 | service1.Name = "hasCheck" 192 | check := monitor.CheckForService(&service1, &mockDiscoverer{}) 193 | So(check.Args, ShouldEqual, "http://indefatigable:1234/status/check") 194 | }) 195 | 196 | Convey("Supports container hostname", func() { 197 | monitor := NewMonitor(hostname, "/") 198 | service1.Name = "containerCheck" 199 | check := monitor.CheckForService(&service1, &mockDiscoverer{}) 200 | So(check.Args, ShouldEqual, "http://indefatigable:1234/status/check") 201 | }) 202 | 203 | Convey("Uses the right default endpoint when it's configured", func() { 204 | monitor := NewMonitor(hostname, "/something/else") 205 | check := monitor.CheckForService(&service1, &mockDiscoverer{}) 206 | So(check.Args, ShouldEqual, "http://indefatigable:1234/something/else") 207 | }) 208 | }) 209 | } 210 | 211 | func Test_GetCommandNamed(t *testing.T) { 212 | Convey("Returns the correct command", t, func() { 213 | monitor := NewMonitor("localhost", "/") 214 | 215 | Convey("When asked for an HttpGet", func() { 216 | So(monitor.GetCommandNamed("HttpGet"), ShouldResemble, 217 | &HttpGetCmd{}, 218 | ) 219 | }) 220 | 221 | Convey("When asked for an ExternalCmd", func() { 222 | So(monitor.GetCommandNamed("External"), ShouldResemble, 223 | &ExternalCmd{}, 224 | ) 225 | }) 226 | 227 | Convey("When asked for an invalid type", func() { 228 | So(monitor.GetCommandNamed("Awesome-sauce"), ShouldResemble, 229 | &HttpGetCmd{}, 230 | ) 231 | }) 232 | }) 233 | } 234 | -------------------------------------------------------------------------------- /logging_bridge.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // This is a bridge to take the output of Memberlist, which uses a standard 10 | // Go logger and reformat them into properly leveled logrus lines. If 11 | // only the stdlib log.Logger were an interface and not a type... 12 | 13 | type LoggingBridge struct { 14 | testing bool 15 | lastLevel []byte 16 | lastMessage []byte 17 | } 18 | 19 | // Memberlist log lines look like: 20 | // 2016/06/24 11:45:33 [DEBUG] memberlist: TCP connection from=172.16.106.1:59598 21 | 22 | // Processes one line at a time from the input. If we somehow 23 | // get less than one line in the input, then weird things will 24 | // happen. Experience shows this doesn't currently happen. 25 | func (l *LoggingBridge) Write(data []byte) (int, error) { 26 | lines := bytes.Split(data, []byte{byte('\n')}) 27 | 28 | bytesWritten := len(lines[0]) 29 | 30 | fields := bytes.Split(lines[0], []byte{byte(' ')}) 31 | l.logMessageAtLevel(bytes.Join(fields[3:], []byte{byte(' ')}), fields[2]) 32 | 33 | return bytesWritten, nil 34 | } 35 | 36 | func (l *LoggingBridge) logMessageAtLevel(message []byte, level []byte) { 37 | if l.testing { 38 | l.lastLevel = level 39 | l.lastMessage = message 40 | } 41 | switch string(level) { 42 | case "[INFO]": 43 | log.Info(string(message)) 44 | case "[WARN]": 45 | log.Warn(string(message)) 46 | case "[ERR]": 47 | log.Error(string(message)) 48 | case "[DEBUG]": 49 | log.Debug(string(message)) 50 | default: 51 | log.Infof("%s %s", string(level), string(message)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /logging_bridge_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func Test_LoggingBridge(t *testing.T) { 10 | Convey("LoggingBridge", t, func() { 11 | bridge := LoggingBridge{testing: true} 12 | 13 | Convey("Properly splits apart and re-levels log messages", func() { 14 | _, err := bridge.Write([]byte("2016/06/24 11:45:33 [DEBUG] memberlist: TCP connection from=172.16.106.1:59598")) 15 | So(err, ShouldBeNil) 16 | 17 | So(string(bridge.lastLevel), ShouldEqual, "[DEBUG]") 18 | So(string(bridge.lastMessage), ShouldEqual, "memberlist: TCP connection from=172.16.106.1:59598") 19 | 20 | _, err = bridge.Write([]byte("2016/06/24 11:45:33 [WARN] memberlist: Something something")) 21 | So(err, ShouldBeNil) 22 | 23 | So(string(bridge.lastLevel), ShouldEqual, "[WARN]") 24 | So(string(bridge.lastMessage), ShouldEqual, "memberlist: Something something") 25 | }) 26 | 27 | Convey("Handles writes that have more than one message", func() { 28 | _, err := bridge.Write( 29 | []byte("2016/06/24 11:45:33 [DEBUG] memberlist: TCP connection from=172.16.106.1:59598\nasdf"), 30 | ) 31 | So(err, ShouldBeNil) 32 | 33 | So(string(bridge.lastMessage), ShouldEqual, "memberlist: TCP connection from=172.16.106.1:59598") 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | func TimeAgo(when time.Time, ref time.Time) string { 9 | diff := ref.Round(time.Second).Sub(when.Round(time.Second)) 10 | 11 | switch { 12 | case when.IsZero(): 13 | return "never" 14 | case diff > time.Hour*24*7: 15 | result := diff.Hours() / 24 / 7 16 | return strconv.FormatFloat(result, 'f', 1, 64) + " weeks ago" 17 | case diff > time.Hour*24: 18 | result := diff.Hours() / 24 19 | return strconv.FormatFloat(result, 'f', 1, 64) + " days ago" 20 | case diff > time.Hour: 21 | result := diff.Hours() 22 | return strconv.FormatFloat(result, 'f', 1, 64) + " hours ago" 23 | case diff > time.Minute: 24 | result := diff.Minutes() 25 | return strconv.FormatFloat(result, 'f', 1, 64) + " mins ago" 26 | case diff > time.Second: 27 | return strconv.FormatFloat(diff.Seconds(), 'f', 1, 64) + " secs ago" 28 | default: 29 | return "1.0 sec ago" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /output/output_test.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func Test_TimeAgo(t *testing.T) { 11 | 12 | Convey("TimeAgo reports durations in friendly increments", t, func() { 13 | baseTime := time.Now().UTC() 14 | secondsAgo := baseTime.Add(0 - 5*time.Second) 15 | minsAgo := baseTime.Add(0 - 5*time.Minute) 16 | hoursAgo := baseTime.Add(0 - 5*time.Hour) 17 | daysAgo := baseTime.Add(0 - 36*time.Hour) 18 | weeksAgo := baseTime.Add(0 - 230*time.Hour) 19 | 20 | tests := map[time.Time]string{ 21 | baseTime: "1.0 sec ago", 22 | secondsAgo: "5.0 secs ago", 23 | minsAgo: "5.0 mins ago", 24 | hoursAgo: "5.0 hours ago", 25 | daysAgo: "1.5 days ago", 26 | weeksAgo: "1.4 weeks ago", 27 | } 28 | 29 | for time, result := range tests { 30 | So(TimeAgo(time, baseTime), ShouldEqual, result) 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /receiver/http.go: -------------------------------------------------------------------------------- 1 | package receiver 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/NinesStack/sidecar/catalog" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type ApiErrors struct { 13 | Errors []string `json:"errors"` 14 | } 15 | 16 | // Receives POSTed state updates from a Sidecar instance 17 | func UpdateHandler(response http.ResponseWriter, req *http.Request, rcvr *Receiver) { 18 | defer req.Body.Close() 19 | response.Header().Set("Content-Type", "application/json") 20 | 21 | data, err := ioutil.ReadAll(req.Body) 22 | if err != nil { 23 | message, _ := json.Marshal(ApiErrors{[]string{err.Error()}}) 24 | response.WriteHeader(http.StatusInternalServerError) 25 | _, err := response.Write(message) 26 | if err != nil { 27 | log.Errorf("Error replying to client when failed to read the request body: %s", err) 28 | } 29 | return 30 | } 31 | 32 | var evt catalog.StateChangedEvent 33 | err = json.Unmarshal(data, &evt) 34 | if err != nil { 35 | message, _ := json.Marshal(ApiErrors{[]string{err.Error()}}) 36 | response.WriteHeader(http.StatusInternalServerError) 37 | _, err := response.Write(message) 38 | if err != nil { 39 | log.Errorf("Error replying to client when failed to unmarshal the request JSON: %s", err) 40 | } 41 | return 42 | } 43 | 44 | rcvr.StateLock.Lock() 45 | defer rcvr.StateLock.Unlock() 46 | 47 | if rcvr.CurrentState == nil || rcvr.CurrentState.LastChanged.Before(evt.State.LastChanged) { 48 | rcvr.CurrentState = evt.State 49 | rcvr.LastSvcChanged = &evt.ChangeEvent.Service 50 | 51 | if ShouldNotify(evt.ChangeEvent.PreviousStatus, evt.ChangeEvent.Service.Status) { 52 | if !rcvr.IsSubscribed(evt.ChangeEvent.Service.Name) { 53 | return 54 | } 55 | 56 | if rcvr.OnUpdate == nil { 57 | log.Errorf("No OnUpdate() callback registered!") 58 | return 59 | } 60 | rcvr.EnqueueUpdate() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /receiver/http_test.go: -------------------------------------------------------------------------------- 1 | package receiver 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/NinesStack/sidecar/catalog" 12 | "github.com/NinesStack/sidecar/service" 13 | "github.com/mohae/deepcopy" 14 | "github.com/relistan/go-director" 15 | . "github.com/smartystreets/goconvey/convey" 16 | ) 17 | 18 | func Test_updateHandler(t *testing.T) { 19 | Convey("updateHandler()", t, func() { 20 | var received bool 21 | var lastReceivedState *catalog.ServicesState 22 | 23 | hostname := "chaucer" 24 | state := catalog.NewServicesState() 25 | state.Servers[hostname] = catalog.NewServer(hostname) 26 | 27 | baseTime := time.Now().UTC() 28 | 29 | svcId := "deadbeef123" 30 | svcId2 := "deadbeef456" 31 | 32 | svc := service.Service{ 33 | ID: svcId, 34 | Name: "bocaccio", 35 | Image: "101deadbeef", 36 | Created: baseTime, 37 | Hostname: hostname, 38 | Updated: baseTime, 39 | Status: service.ALIVE, 40 | } 41 | 42 | svc2 := service.Service{ 43 | ID: svcId2, 44 | Name: "shakespeare", 45 | Image: "202deadbeef", 46 | Created: baseTime, 47 | Hostname: hostname, 48 | Updated: baseTime, 49 | Status: service.ALIVE, 50 | } 51 | 52 | state.AddServiceEntry(svc) 53 | state.AddServiceEntry(svc2) 54 | 55 | req := httptest.NewRequest("POST", "/update", nil) 56 | recorder := httptest.NewRecorder() 57 | 58 | // Make it possible to see if we got an update, and to wait for it to happen 59 | rcvr := NewReceiver(10, func(state *catalog.ServicesState) { received = true; lastReceivedState = state }) 60 | rcvr.Looper = director.NewFreeLooper(director.ONCE, nil) 61 | rcvr.CurrentState = state 62 | 63 | Convey("returns an error on an invalid JSON body", func() { 64 | UpdateHandler(recorder, req, rcvr) 65 | 66 | resp := recorder.Result() 67 | So(resp.StatusCode, ShouldEqual, 500) 68 | 69 | bodyBytes, _ := ioutil.ReadAll(resp.Body) 70 | So(string(bodyBytes), ShouldContainSubstring, "unexpected end of JSON input") 71 | }) 72 | 73 | Convey("updates the state and enqueues an update", func() { 74 | startTime := rcvr.CurrentState.LastChanged 75 | 76 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 77 | evtState.LastChanged = time.Now().UTC() 78 | 79 | change := catalog.StateChangedEvent{ 80 | State: evtState, 81 | ChangeEvent: catalog.ChangeEvent{ 82 | Service: service.Service{ 83 | ID: "10101010101", 84 | Updated: time.Now().UTC(), 85 | Created: time.Now().UTC(), 86 | Status: service.ALIVE, 87 | }, 88 | PreviousStatus: service.TOMBSTONE, 89 | }, 90 | } 91 | 92 | encoded, _ := json.Marshal(change) 93 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 94 | 95 | UpdateHandler(recorder, req, rcvr) 96 | resp := recorder.Result() 97 | 98 | So(resp.StatusCode, ShouldEqual, 200) 99 | So(startTime.Before(rcvr.CurrentState.LastChanged), ShouldBeTrue) 100 | 101 | So(received, ShouldBeFalse) 102 | rcvr.ProcessUpdates() 103 | So(rcvr.CurrentState.LastChanged, ShouldResemble, evtState.LastChanged) 104 | So(rcvr.LastSvcChanged, ShouldResemble, &change.ChangeEvent.Service) 105 | So(received, ShouldBeTrue) 106 | }) 107 | 108 | Convey("enqueues all updates if no Subscriptions are provided", func() { 109 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 110 | evtState.LastChanged = time.Now().UTC() 111 | 112 | change := catalog.StateChangedEvent{ 113 | State: evtState, 114 | ChangeEvent: catalog.ChangeEvent{ 115 | Service: service.Service{ 116 | Name: "nobody-wants-this", 117 | ID: "10101010101", 118 | Updated: time.Now().UTC(), 119 | Created: time.Now().UTC(), 120 | Status: service.ALIVE, 121 | }, 122 | PreviousStatus: service.TOMBSTONE, 123 | }, 124 | } 125 | 126 | encoded, _ := json.Marshal(change) 127 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 128 | 129 | UpdateHandler(recorder, req, rcvr) 130 | resp := recorder.Result() 131 | 132 | So(resp.StatusCode, ShouldEqual, 200) 133 | So(len(rcvr.ReloadChan), ShouldEqual, 1) 134 | }) 135 | 136 | Convey("does not enqueue updates if the service is not subscribed to", func() { 137 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 138 | evtState.LastChanged = time.Now().UTC() 139 | 140 | change := catalog.StateChangedEvent{ 141 | State: evtState, 142 | ChangeEvent: catalog.ChangeEvent{ 143 | Service: service.Service{ 144 | Name: "nobody-wants-this", 145 | ID: "10101010101", 146 | Updated: time.Now().UTC(), 147 | Created: time.Now().UTC(), 148 | Status: service.ALIVE, 149 | }, 150 | PreviousStatus: service.TOMBSTONE, 151 | }, 152 | } 153 | 154 | rcvr.Subscribe("another-service") 155 | 156 | encoded, _ := json.Marshal(change) 157 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 158 | 159 | UpdateHandler(recorder, req, rcvr) 160 | resp := recorder.Result() 161 | 162 | So(resp.StatusCode, ShouldEqual, 200) 163 | So(len(rcvr.ReloadChan), ShouldEqual, 0) 164 | }) 165 | 166 | Convey("enqueues updates if the service is subscribed to", func() { 167 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 168 | evtState.LastChanged = time.Now().UTC() 169 | 170 | change := catalog.StateChangedEvent{ 171 | State: evtState, 172 | ChangeEvent: catalog.ChangeEvent{ 173 | Service: service.Service{ 174 | Name: "subscribed-service", 175 | ID: "10101010101", 176 | Updated: time.Now().UTC(), 177 | Created: time.Now().UTC(), 178 | Status: service.ALIVE, 179 | }, 180 | PreviousStatus: service.TOMBSTONE, 181 | }, 182 | } 183 | 184 | rcvr.Subscribe("subscribed-service") 185 | 186 | encoded, _ := json.Marshal(change) 187 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 188 | 189 | UpdateHandler(recorder, req, rcvr) 190 | resp := recorder.Result() 191 | 192 | So(resp.StatusCode, ShouldEqual, 200) 193 | So(len(rcvr.ReloadChan), ShouldEqual, 1) 194 | }) 195 | 196 | Convey("a copy of the state is passed to the OnUpdate func", func() { 197 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 198 | evtState.LastChanged = time.Now().UTC() 199 | 200 | change := catalog.StateChangedEvent{ 201 | State: evtState, 202 | ChangeEvent: catalog.ChangeEvent{ 203 | Service: service.Service{ 204 | ID: "10101010101", 205 | Updated: time.Now().UTC(), 206 | Created: time.Now().UTC(), 207 | Status: service.ALIVE, 208 | }, 209 | PreviousStatus: service.TOMBSTONE, 210 | }, 211 | } 212 | 213 | encoded, _ := json.Marshal(change) 214 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 215 | 216 | UpdateHandler(recorder, req, rcvr) 217 | 218 | rcvr.ProcessUpdates() 219 | 220 | // Make sure ongoing state changes don't affect the state the receiver passes on 221 | state.LastChanged = time.Now().UTC() 222 | state.Servers["chaucer"].Services = make(map[string]*service.Service) 223 | So(lastReceivedState.LastChanged.Before(state.LastChanged), ShouldBeTrue) 224 | So(len(lastReceivedState.Servers["chaucer"].Services), ShouldEqual, 2) 225 | }) 226 | 227 | Convey("enqueues an update to mark a service as DRAINING", func() { 228 | evtState := deepcopy.Copy(state).(*catalog.ServicesState) 229 | evtState.LastChanged = time.Now().UTC() 230 | 231 | change := catalog.StateChangedEvent{ 232 | State: evtState, 233 | ChangeEvent: catalog.ChangeEvent{ 234 | Service: service.Service{ 235 | Name: "nobody-wants-this", 236 | ID: "10101010101", 237 | Updated: time.Now().UTC(), 238 | Created: time.Now().UTC(), 239 | Status: service.DRAINING, 240 | }, 241 | PreviousStatus: service.ALIVE, 242 | }, 243 | } 244 | 245 | encoded, _ := json.Marshal(change) 246 | req := httptest.NewRequest("POST", "/update", bytes.NewBuffer(encoded)) 247 | 248 | UpdateHandler(recorder, req, rcvr) 249 | resp := recorder.Result() 250 | 251 | So(resp.StatusCode, ShouldEqual, 200) 252 | So(len(rcvr.ReloadChan), ShouldEqual, 1) 253 | }) 254 | }) 255 | } 256 | -------------------------------------------------------------------------------- /receiver/receiver.go: -------------------------------------------------------------------------------- 1 | package receiver 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/NinesStack/sidecar/catalog" 11 | "github.com/NinesStack/sidecar/service" 12 | "github.com/mohae/deepcopy" 13 | "github.com/relistan/go-director" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const ( 18 | RELOAD_HOLD_DOWN = 5 * time.Second // Reload at worst every 5 seconds 19 | ) 20 | 21 | type Receiver struct { 22 | StateLock sync.Mutex 23 | ReloadChan chan time.Time 24 | CurrentState *catalog.ServicesState 25 | LastSvcChanged *service.Service 26 | OnUpdate func(state *catalog.ServicesState) 27 | Looper director.Looper 28 | Subscriptions []string 29 | } 30 | 31 | func NewReceiver(capacity int, onUpdate func(state *catalog.ServicesState)) *Receiver { 32 | return &Receiver{ 33 | ReloadChan: make(chan time.Time, capacity), 34 | OnUpdate: onUpdate, 35 | Looper: director.NewImmediateTimedLooper(director.FOREVER, RELOAD_HOLD_DOWN, make(chan error)), 36 | } 37 | } 38 | 39 | // Check all the state transitions and only update HAproxy when a change 40 | // will affect service availability. 41 | func ShouldNotify(oldStatus int, newStatus int) bool { 42 | log.Debugf("Checking event. OldStatus: %s NewStatus: %s", 43 | service.StatusString(oldStatus), service.StatusString(newStatus), 44 | ) 45 | 46 | // Compare old and new states to find significant changes only 47 | switch newStatus { 48 | case service.ALIVE: 49 | return true 50 | case service.TOMBSTONE: 51 | return true 52 | case service.UNKNOWN: 53 | if oldStatus == service.ALIVE { 54 | return true 55 | } 56 | case service.UNHEALTHY: 57 | if oldStatus == service.ALIVE { 58 | return true 59 | } 60 | case service.DRAINING: 61 | return true 62 | default: 63 | log.Errorf("Got unknown service change status: %d", newStatus) 64 | return false 65 | } 66 | 67 | log.Debugf("Skipped HAproxy update due to state machine check") 68 | return false 69 | } 70 | 71 | // Used to fetch the current state from a Sidecar endpoint, usually 72 | // on startup of this process, when the currentState is empty. 73 | func FetchState(url string) (*catalog.ServicesState, error) { 74 | client := &http.Client{Timeout: 5 * time.Second} 75 | resp, err := client.Get(url) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 81 | return nil, fmt.Errorf("Bad status code on state fetch: %d", resp.StatusCode) 82 | } 83 | 84 | bytes, err := ioutil.ReadAll(resp.Body) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | state, err := catalog.Decode(bytes) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | return state, nil 95 | } 96 | 97 | // IsSubscribed allows a receiver to filter incoming events by service name 98 | func (rcvr *Receiver) IsSubscribed(svcName string) bool { 99 | // If we didn't specify any specifically, then we want them all 100 | if len(rcvr.Subscriptions) < 1 { 101 | return true 102 | } 103 | 104 | for _, subName := range rcvr.Subscriptions { 105 | if subName == svcName { 106 | return true 107 | } 108 | } 109 | 110 | return false 111 | } 112 | 113 | // Subscribe is not synchronized and should not be called dynamically. This 114 | // is generally used at setup of the Receiver, before any events begin arriving. 115 | func (rcvr *Receiver) Subscribe(svcName string) { 116 | for _, subName := range rcvr.Subscriptions { 117 | if subName == svcName { 118 | return 119 | } 120 | } 121 | 122 | rcvr.Subscriptions = append(rcvr.Subscriptions, svcName) 123 | } 124 | 125 | // ProcessUpdates loops forever, processing updates to the state. 126 | // By the time we get here, the HTTP UpdateHandler has already set the 127 | // CurrentState to the newest state we know about. Here we'll try to group 128 | // updates together to prevent repeatedly updating on a series of events that 129 | // arrive in a row. 130 | func (rcvr *Receiver) ProcessUpdates() { 131 | if rcvr.Looper == nil { 132 | log.Error("Unable to ProcessUpdates(), Looper is nil in receiver!") 133 | return 134 | } 135 | 136 | rcvr.Looper.Loop(func() error { 137 | // Batch up to RELOAD_BUFFER number updates into a 138 | // single update. 139 | first := <-rcvr.ReloadChan 140 | pending := len(rcvr.ReloadChan) 141 | 142 | // Call the callback 143 | if rcvr.OnUpdate == nil { 144 | log.Error("OnUpdate() callback not defined!") 145 | } else { 146 | rcvr.StateLock.Lock() 147 | // Copy the state while locked so we don't have it change 148 | // under us while writing and we don't hold onto the lock the 149 | // whole time we're writing to disk (e.g. in haproxy-api). 150 | tmpState := deepcopy.Copy(rcvr.CurrentState).(*catalog.ServicesState) 151 | rcvr.StateLock.Unlock() 152 | 153 | rcvr.OnUpdate(tmpState) 154 | } 155 | 156 | // We just flushed the most recent state, dump all the 157 | // pending items up to that point. 158 | var reload time.Time 159 | for i := 0; i < pending; i++ { 160 | reload = <-rcvr.ReloadChan 161 | } 162 | 163 | if pending > 0 { 164 | log.Infof("Skipped %d messages between %s and %s", pending, first, reload) 165 | } 166 | 167 | // Don't notify more frequently than every RELOAD_HOLD_DOWN period. When a 168 | // deployment rolls across the cluster it can trigger a bunch of groupable 169 | // updates. The Looper handles the sleep after the return nil. 170 | log.Debug("Holding down...") 171 | 172 | return nil 173 | }) 174 | } 175 | 176 | // EnqueueUpdate puts a new timestamp on the update channel, to be processed in a 177 | // goroutine that runs the ProcessUpdates function. 178 | func (rcvr *Receiver) EnqueueUpdate() { 179 | rcvr.ReloadChan <- time.Now().UTC() 180 | } 181 | 182 | // FetchInitialState is used at startup to bootstrap initial state from Sidecar. 183 | func (rcvr *Receiver) FetchInitialState(stateUrl string) error { 184 | rcvr.StateLock.Lock() 185 | defer rcvr.StateLock.Unlock() 186 | 187 | log.Info("Fetching initial state on startup...") 188 | state, err := FetchState(stateUrl) 189 | if err != nil { 190 | return err 191 | } else { 192 | log.Info("Successfully retrieved state") 193 | rcvr.CurrentState = state 194 | if rcvr.OnUpdate == nil { 195 | log.Error("OnUpdate() callback not defined!") 196 | } else { 197 | rcvr.OnUpdate(state) 198 | } 199 | } 200 | 201 | return nil 202 | } 203 | -------------------------------------------------------------------------------- /receiver/receiver_test.go: -------------------------------------------------------------------------------- 1 | package receiver 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/NinesStack/sidecar/catalog" 8 | . "github.com/smartystreets/goconvey/convey" 9 | "gopkg.in/jarcoal/httpmock.v1" 10 | ) 11 | 12 | func Test_FetchState(t *testing.T) { 13 | Convey("FetchState()", t, func() { 14 | stateUrl := "http://localhost:7777/api/state.json" 15 | httpmock.Activate() 16 | Reset(func() { 17 | httpmock.DeactivateAndReset() 18 | }) 19 | 20 | Convey("returns an error on a bad status code", func() { 21 | httpmock.RegisterResponder( 22 | "GET", stateUrl, 23 | func(req *http.Request) (*http.Response, error) { 24 | return httpmock.NewStringResponse(500, "so bad!"), nil 25 | }, 26 | ) 27 | catalog, err := FetchState(stateUrl) 28 | 29 | So(catalog, ShouldBeNil) 30 | So(err, ShouldNotBeNil) 31 | So(err.Error(), ShouldContainSubstring, "Bad status code") 32 | }) 33 | 34 | Convey("returns an error on a bad json body", func() { 35 | httpmock.RegisterResponder( 36 | "GET", stateUrl, 37 | func(req *http.Request) (*http.Response, error) { 38 | return httpmock.NewStringResponse(200, "so bad!"), nil 39 | }, 40 | ) 41 | catalog, err := FetchState(stateUrl) 42 | 43 | So(catalog, ShouldBeNil) 44 | So(err, ShouldNotBeNil) 45 | So(err.Error(), ShouldContainSubstring, "ffjson error") 46 | }) 47 | 48 | Convey("returns a valid ServicesState on success", func() { 49 | state := catalog.NewServicesState() 50 | 51 | httpmock.RegisterResponder( 52 | "GET", stateUrl, 53 | func(req *http.Request) (*http.Response, error) { 54 | return httpmock.NewStringResponse(200, string(state.Encode())), nil 55 | }, 56 | ) 57 | receivedState, err := FetchState(stateUrl) 58 | 59 | So(receivedState, ShouldNotBeNil) 60 | So(err, ShouldBeNil) 61 | So(receivedState.Servers, ShouldNotBeNil) 62 | }) 63 | }) 64 | } 65 | 66 | func Test_IsSubscribed(t *testing.T) { 67 | Convey("IsSubscribed()", t, func() { 68 | rcvr := &Receiver{} 69 | 70 | Convey("returns true when a service is subscribed", func() { 71 | rcvr.Subscriptions = []string{"some-svc"} 72 | 73 | So(rcvr.IsSubscribed("some-svc"), ShouldBeTrue) 74 | }) 75 | 76 | Convey("returns false when a service is NOT subscribed", func() { 77 | rcvr.Subscriptions = []string{"some-svc"} 78 | 79 | So(rcvr.IsSubscribed("another-svc"), ShouldBeFalse) 80 | }) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | //go:generate ffjson $GOFILE 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/NinesStack/sidecar/output" 13 | docker "github.com/fsouza/go-dockerclient" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const ( 18 | ALIVE = iota 19 | TOMBSTONE = iota 20 | UNHEALTHY = iota 21 | UNKNOWN = iota 22 | DRAINING = iota 23 | ) 24 | 25 | type Port struct { 26 | Type string 27 | Port int64 28 | ServicePort int64 29 | IP string 30 | } 31 | 32 | type Service struct { 33 | ID string 34 | Name string 35 | Image string 36 | Created time.Time 37 | Hostname string 38 | Ports []Port 39 | Updated time.Time 40 | ProxyMode string 41 | Status int 42 | } 43 | 44 | func (svc *Service) Encode() ([]byte, error) { 45 | return svc.MarshalJSON() 46 | } 47 | 48 | func (svc *Service) StatusString() string { 49 | return StatusString(svc.Status) 50 | } 51 | 52 | func (svc *Service) IsAlive() bool { 53 | return svc.Status == ALIVE 54 | } 55 | 56 | func (svc *Service) IsTombstone() bool { 57 | return svc.Status == TOMBSTONE 58 | } 59 | 60 | func (svc *Service) IsDraining() bool { 61 | return svc.Status == DRAINING 62 | } 63 | 64 | func (svc *Service) Invalidates(otherSvc *Service) bool { 65 | return otherSvc != nil && svc.Updated.After(otherSvc.Updated) 66 | } 67 | 68 | func (svc *Service) IsStale(lifespan time.Duration) bool { 69 | oldestAllowed := time.Now().UTC().Add(0 - lifespan) 70 | // We add a fudge factor for clock drift 71 | return svc.Updated.Before(oldestAllowed.Add(0 - 1*time.Minute)) 72 | } 73 | 74 | func (svc *Service) Format() string { 75 | var ports []string 76 | for _, port := range svc.Ports { 77 | ports = append(ports, 78 | fmt.Sprintf("%d->%d", port.ServicePort, port.Port), 79 | ) 80 | } 81 | return fmt.Sprintf(" %s %-30s %-15s %-45s %-15s %-9s\n", 82 | svc.ID, 83 | svc.Name, 84 | strings.Join(ports, ","), 85 | svc.Image, 86 | output.TimeAgo(svc.Updated, time.Now().UTC()), 87 | svc.StatusString(), 88 | ) 89 | } 90 | 91 | func (svc *Service) Tombstone() { 92 | svc.Status = TOMBSTONE 93 | svc.Updated = time.Now().UTC() 94 | } 95 | 96 | // Look up a (usually Docker) mapped Port for a service by ServicePort 97 | func (svc *Service) PortForServicePort(findPort int64, pType string) int64 { 98 | for _, port := range svc.Ports { 99 | if port.ServicePort == findPort && port.Type == pType { 100 | return port.Port 101 | } 102 | } 103 | 104 | log.Warnf("Unable to find ServicePort %d for service %s", findPort, svc.ID) 105 | return -1 106 | } 107 | 108 | // ListenerName returns the string name this service should be identified 109 | // by as a listener to Sidecar state 110 | func (svc *Service) ListenerName() string { 111 | return "Service(" + svc.Name + "-" + svc.ID + ")" 112 | } 113 | 114 | // Version attempts to extract a version from the image. Otherwise it returns 115 | // the full image name. 116 | func (svc *Service) Version() string { 117 | parts := strings.Split(svc.Image, ":") 118 | if len(parts) > 1 { 119 | return parts[1] 120 | } 121 | 122 | return parts[0] 123 | } 124 | 125 | // Decode decodes the input data JSON into a *Service. If it fails, it returns 126 | // a non-nil error 127 | func Decode(data []byte) (*Service, error) { 128 | var svc Service 129 | err := svc.UnmarshalJSON(data) 130 | if err != nil { 131 | return nil, fmt.Errorf("failed to decode service JSON: %s", err) 132 | } 133 | 134 | return &svc, nil 135 | } 136 | 137 | // Format an APIContainers struct into a more compact struct we 138 | // can ship over the wire in a broadcast. 139 | func ToService(container *docker.APIContainers, ip string) Service { 140 | var svc Service 141 | hostname, _ := os.Hostname() 142 | 143 | svc.ID = container.ID[0:12] // Use short IDs 144 | svc.Name = container.Names[0] // Use the first name 145 | svc.Image = container.Image 146 | svc.Created = time.Unix(container.Created, 0).UTC() 147 | svc.Updated = time.Now().UTC() 148 | svc.Hostname = hostname 149 | svc.Status = ALIVE 150 | 151 | if _, ok := container.Labels["ProxyMode"]; ok { 152 | svc.ProxyMode = container.Labels["ProxyMode"] 153 | } else { 154 | svc.ProxyMode = "http" 155 | } 156 | 157 | svc.Ports = make([]Port, 0) 158 | 159 | for _, port := range container.Ports { 160 | if port.PublicPort != 0 { 161 | svc.Ports = append(svc.Ports, buildPortFor(&port, container, ip)) 162 | } 163 | } 164 | 165 | return svc 166 | } 167 | 168 | func StatusString(status int) string { 169 | switch status { 170 | case ALIVE: 171 | return "Alive" 172 | case UNHEALTHY: 173 | return "Unhealthy" 174 | case UNKNOWN: 175 | return "Unknown" 176 | case DRAINING: 177 | return "Draining" 178 | default: 179 | return "Tombstone" 180 | } 181 | } 182 | 183 | // Figure out the correct port configuration for a service 184 | func buildPortFor(port *docker.APIPort, container *docker.APIContainers, ip string) Port { 185 | // We look up service port labels by convention in the format "ServicePort_80=8080" 186 | svcPortLabel := fmt.Sprintf("ServicePort_%d", port.PrivatePort) 187 | 188 | // You can override the default IP by binding your container on a specific IP 189 | if port.IP != "0.0.0.0" && port.IP != "" { 190 | ip = port.IP 191 | } 192 | 193 | returnPort := Port{Port: port.PublicPort, Type: port.Type, IP: ip} 194 | 195 | if svcPort, ok := container.Labels[svcPortLabel]; ok { 196 | svcPortInt, err := strconv.Atoi(svcPort) 197 | if err != nil { 198 | log.Errorf("Error converting label value for %s to integer: %s", 199 | svcPortLabel, 200 | err, 201 | ) 202 | return returnPort 203 | } 204 | 205 | // Everything was good, set the service port 206 | returnPort.ServicePort = int64(svcPortInt) 207 | } 208 | 209 | return returnPort 210 | } 211 | 212 | type ByID []Service 213 | 214 | func (a ByID) Len() int { return len(a) } 215 | func (a ByID) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 216 | func (a ByID) Less(i, j int) bool { return strings.Compare(a[i].ID, a[j].ID) == -1 } 217 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | docker "github.com/fsouza/go-dockerclient" 9 | . "github.com/smartystreets/goconvey/convey" 10 | ) 11 | 12 | func Test_PortForServicePort(t *testing.T) { 13 | Convey("PortForServicePort()", t, func() { 14 | svc := &Service{ 15 | ID: "deadbeef001", 16 | Ports: []Port{ 17 | {"tcp", 8173, 8080, "127.0.0.1"}, 18 | {"udp", 8172, 8080, "127.0.0.1"}, 19 | }, 20 | } 21 | 22 | Convey("Returns the port when it matches", func() { 23 | So(svc.PortForServicePort(8080, "tcp"), ShouldEqual, 8173) 24 | }) 25 | 26 | Convey("Returns -1 when there is no match", func() { 27 | So(svc.PortForServicePort(8090, "tcp"), ShouldEqual, -1) 28 | }) 29 | }) 30 | } 31 | 32 | func Test_buildPortFor(t *testing.T) { 33 | Convey("buildPortFor()", t, func() { 34 | dPort := docker.APIPort{ 35 | PrivatePort: 80, 36 | PublicPort: 8723, 37 | Type: "tcp", 38 | } 39 | 40 | ip := "127.0.0.1" 41 | 42 | container := &docker.APIContainers{ 43 | Ports: []docker.APIPort{dPort}, 44 | Labels: map[string]string{ 45 | "ServicePort_80": "8080", 46 | }, 47 | } 48 | 49 | Convey("Maps service ports to internal ports", func() { 50 | port := buildPortFor(&dPort, container, ip) 51 | 52 | So(port.ServicePort, ShouldEqual, 8080) 53 | So(port.Port, ShouldEqual, 8723) 54 | So(port.Type, ShouldEqual, "tcp") 55 | }) 56 | 57 | Convey("Adds the default IP address", func() { 58 | port := buildPortFor(&dPort, container, ip) 59 | 60 | So(port.IP, ShouldEqual, ip) 61 | }) 62 | 63 | Convey("Skips the service port when there is none", func() { 64 | delete(container.Labels, "ServicePort_80") 65 | port := buildPortFor(&dPort, container, ip) 66 | 67 | So(port.ServicePort, ShouldEqual, 0) 68 | So(port.Port, ShouldEqual, 8723) 69 | So(port.Type, ShouldEqual, "tcp") 70 | }) 71 | 72 | Convey("Skips the service port when there is a conversion error", func() { 73 | container.Labels["ServicePort_80"] = "not a number" 74 | port := buildPortFor(&dPort, container, ip) 75 | 76 | So(port.ServicePort, ShouldEqual, 0) 77 | So(port.Port, ShouldEqual, 8723) 78 | So(port.Type, ShouldEqual, "tcp") 79 | }) 80 | }) 81 | } 82 | 83 | func Test_ToService(t *testing.T) { 84 | sampleAPIContainer := &docker.APIContainers{ 85 | ID: "88862023487fa0ae043c47d7b441f684fc39145d1d9fa398450e4da2e53af5e8", 86 | Image: "example.com/docker/fabulous-container:latest", 87 | Command: "/fabulous_app", 88 | Created: 1457144774, 89 | Status: "Up 34 seconds", 90 | Ports: []docker.APIPort{ 91 | { 92 | PrivatePort: 9990, 93 | PublicPort: 0, 94 | Type: "tcp", 95 | IP: "", 96 | }, 97 | { 98 | PrivatePort: 8080, 99 | PublicPort: 31355, 100 | Type: "tcp", 101 | IP: "192.168.77.13", 102 | }, 103 | }, 104 | SizeRw: 0, 105 | SizeRootFs: 0, 106 | Names: []string{"/sample-app-go-worker-eebb5aad1a17ee"}, 107 | Labels: map[string]string{ 108 | "ServicePort_8080": "17010", 109 | "ProxyMode": "tcp", 110 | "HealthCheck": "HttpGet", 111 | "HealthCheckArgs": "http://127.0.0.1:39519/status/check", 112 | }, 113 | } 114 | 115 | samplePorts := []Port{ 116 | { 117 | Type: "tcp", 118 | Port: 31355, 119 | ServicePort: 17010, 120 | IP: "192.168.77.13", 121 | }, 122 | } 123 | 124 | sampleHostname, _ := os.Hostname() 125 | 126 | Convey("ToService()", t, func() { 127 | Convey("Decodes service correctly", func() { 128 | service := ToService(sampleAPIContainer, "127.0.0.1") 129 | So(service.ID, ShouldEqual, sampleAPIContainer.ID[:12]) 130 | So(service.Image, ShouldEqual, sampleAPIContainer.Image) 131 | So(service.Name, ShouldEqual, sampleAPIContainer.Names[0]) 132 | So(service.Created.String(), ShouldEqual, "2016-03-05 02:26:14 +0000 UTC") 133 | So(service.Hostname, ShouldEqual, sampleHostname) 134 | So(samplePorts, ShouldResemble, service.Ports) 135 | So(service.Updated, ShouldNotBeNil) 136 | So(service.ProxyMode, ShouldEqual, "tcp") 137 | So(service.Status, ShouldEqual, 0) 138 | }) 139 | }) 140 | } 141 | 142 | func Test_IsStale(t *testing.T) { 143 | Convey("IsStale()", t, func() { 144 | Convey("identifies records that are too old to process", func() { 145 | lifespan := 1 * time.Hour 146 | lastUpdated := time.Now().UTC().Add(0 - lifespan).Add(0 - 2*time.Minute) 147 | 148 | svc := &Service{ 149 | Name: "hrunting", 150 | Updated: lastUpdated, 151 | Hostname: "beowulf", 152 | } 153 | 154 | So(svc.IsStale(lifespan), ShouldBeTrue) 155 | 156 | svc.Updated = time.Now().UTC().Add(0 - lifespan) 157 | So(svc.IsStale(62*time.Minute), ShouldBeFalse) 158 | }) 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /services_delegate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/NinesStack/memberlist" 9 | "github.com/NinesStack/sidecar/catalog" 10 | "github.com/NinesStack/sidecar/service" 11 | metrics "github.com/armon/go-metrics" 12 | "github.com/pquerna/ffjson/ffjson" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const ( 17 | MAX_PENDING_LENGTH = 100 // Number of messages we can replace into the pending queue 18 | ) 19 | 20 | type servicesDelegate struct { 21 | state *catalog.ServicesState 22 | pendingBroadcasts [][]byte 23 | notifications chan []byte 24 | Started bool 25 | StartedAt time.Time 26 | Metadata NodeMetadata 27 | } 28 | 29 | type NodeMetadata struct { 30 | ClusterName string 31 | State string 32 | } 33 | 34 | func NewServicesDelegate(state *catalog.ServicesState) *servicesDelegate { 35 | delegate := servicesDelegate{ 36 | state: state, 37 | pendingBroadcasts: make([][]byte, 0), 38 | notifications: make(chan []byte, 25), 39 | Metadata: NodeMetadata{ClusterName: "default"}, 40 | } 41 | 42 | return &delegate 43 | } 44 | 45 | // Start kicks off the goroutine that will process incoming notifications of services 46 | func (d *servicesDelegate) Start() { 47 | go func() { 48 | for message := range d.notifications { 49 | entry, err := service.Decode(message) 50 | if err != nil { 51 | log.Errorf("Start(): error decoding message: %s", err) 52 | continue 53 | } 54 | d.state.UpdateService(*entry) 55 | } 56 | }() 57 | 58 | d.Started = true 59 | d.StartedAt = time.Now().UTC() 60 | } 61 | 62 | func (d *servicesDelegate) NodeMeta(limit int) []byte { 63 | log.Debugf("NodeMeta(): %d", limit) 64 | data, err := json.Marshal(d.Metadata) 65 | if err != nil { 66 | log.Error("Error encoding Node metadata!") 67 | data = []byte("{}") 68 | } 69 | return data 70 | } 71 | 72 | func (d *servicesDelegate) NotifyMsg(message []byte) { 73 | defer metrics.MeasureSince([]string{"delegate", "NotifyMsg"}, time.Now()) 74 | 75 | if len(message) < 1 { 76 | log.Debug("NotifyMsg(): empty") 77 | return 78 | } 79 | 80 | log.Debugf("NotifyMsg(): %s", string(message)) 81 | 82 | d.notifications <- message 83 | } 84 | 85 | func (d *servicesDelegate) GetBroadcasts(overhead, limit int) [][]byte { 86 | defer metrics.MeasureSince([]string{"delegate", "GetBroadcasts"}, time.Now()) 87 | metrics.SetGauge([]string{"delegate", "pendingBroadcasts"}, float32(len(d.pendingBroadcasts))) 88 | 89 | log.Debugf("GetBroadcasts(): %d %d", overhead, limit) 90 | 91 | var broadcast [][]byte 92 | 93 | select { 94 | case broadcast = <-d.state.Broadcasts: 95 | default: 96 | if len(d.pendingBroadcasts) < 1 { 97 | return nil 98 | } 99 | } 100 | 101 | // Prefer newest messages (TODO what about tombstones?). We use the one new 102 | // broadcast and then append all the pending ones to see if we can get 103 | // them into the packet. 104 | if len(d.pendingBroadcasts) > 0 { 105 | broadcast = append(broadcast, d.pendingBroadcasts...) 106 | } 107 | broadcast, leftover := d.packPacket(broadcast, limit, overhead) 108 | 109 | if len(leftover) > 0 { 110 | // We don't want to store old messages forever, or starve ourselves to death 111 | if len(leftover) > MAX_PENDING_LENGTH { 112 | d.pendingBroadcasts = leftover[:MAX_PENDING_LENGTH] 113 | } else { 114 | d.pendingBroadcasts = leftover 115 | } 116 | 117 | log.Debugf("Leaving %d messages unsent", len(leftover)) 118 | } else { 119 | d.pendingBroadcasts = [][]byte{} 120 | } 121 | 122 | if broadcast == nil || len(broadcast) < 1 { 123 | log.Debug("Note: Not enough space to fit any messages or message was nil") 124 | return nil 125 | } 126 | 127 | log.Debugf("Sending broadcast %d msgs %d 1st length", 128 | len(broadcast), len(broadcast[0]), 129 | ) 130 | 131 | // Unfortunately Memberlist does not provide a callback after broadcasts were 132 | // accepted so we have no direct way to return these to the pool. However, it 133 | // immediately copies what we return into a new buffer. So, it's not perfectly, 134 | // but is reasonably safe to wait awhile and then re-add our buffer to the 135 | // ffjson pool. 136 | go func(broadcast [][]byte) { 137 | time.Sleep(25 * time.Millisecond) // Lots of safety margin in this number 138 | for i := 0; i < len(broadcast); i++ { 139 | ffjson.Pool(broadcast[i]) 140 | } 141 | }(broadcast) 142 | 143 | return broadcast 144 | } 145 | 146 | func (d *servicesDelegate) LocalState(join bool) []byte { 147 | log.Debugf("LocalState(): %t", join) 148 | d.state.RLock() 149 | defer d.state.RUnlock() 150 | return d.state.Encode() 151 | } 152 | 153 | func (d *servicesDelegate) MergeRemoteState(buf []byte, join bool) { 154 | defer metrics.MeasureSince([]string{"delegate", "MergeRemoteState"}, time.Now()) 155 | 156 | log.Debugf("MergeRemoteState(): %s %t", string(buf), join) 157 | 158 | otherState, err := catalog.Decode(buf) 159 | if err != nil { 160 | log.Errorf("Failed to MergeRemoteState(): %s", err.Error()) 161 | return 162 | } 163 | 164 | log.Debugf("Merging state: %s", otherState.Format(nil)) 165 | 166 | d.state.Merge(otherState) 167 | } 168 | 169 | func (d *servicesDelegate) NotifyJoin(node *memberlist.Node) { 170 | log.Debugf("NotifyJoin(): %s %s", node.Name, string(node.Meta)) 171 | } 172 | 173 | func (d *servicesDelegate) NotifyLeave(node *memberlist.Node) { 174 | log.Debugf("NotifyLeave(): %s", node.Name) 175 | go d.state.ExpireServer(node.Name) 176 | } 177 | 178 | func (d *servicesDelegate) NotifyUpdate(node *memberlist.Node) { 179 | log.Debugf("NotifyUpdate(): %s", node.Name) 180 | } 181 | 182 | // Try to pack as many messages into the packet as we can. Note that this 183 | // assumes that no messages will be longer than the normal UDP packet size. 184 | // This means that max message length is somewhere around 1398 when taking 185 | // messaging overhead into account. 186 | func (d *servicesDelegate) packPacket(broadcasts [][]byte, limit int, overhead int) (packet [][]byte, leftover [][]byte) { 187 | total := 0 188 | lastItem := -1 189 | 190 | // Find the index of the last item that fits into the packet we're building 191 | for i, message := range broadcasts { 192 | if total+len(message)+overhead > limit { 193 | break 194 | } 195 | 196 | lastItem = i 197 | total += len(message) + overhead 198 | } 199 | 200 | if lastItem < 0 && len(broadcasts) > 0 { 201 | // Don't warn on startup... it's fairly normal 202 | gracePeriod := time.Now().UTC().Add(0 - (5 * time.Second)) 203 | if d.StartedAt.Before(gracePeriod) { 204 | // Sample this so that we don't go apeshit logging when there is something 205 | // a bit blocked up. We'll log 1/50th of the time. 206 | if rand.Intn(50) == 1 { 207 | log.Warnf("All messages were too long to fit! No broadcasts!") 208 | } 209 | } 210 | 211 | // There could be a scenario here where one hugely long broadcast could 212 | // get stuck forever and prevent anything else from going out. There 213 | // may be a better way to handle this. Scanning for the next message that 214 | // does fit results in lots of memory copying and doesn't perform at scale. 215 | return nil, broadcasts 216 | } 217 | 218 | // Save the leftover messages after the last one that fit. If this is too 219 | // much, then set it to the lastItem. 220 | firstLeftover := lastItem + 1 221 | 222 | return broadcasts[:lastItem+1], broadcasts[firstLeftover:] 223 | } 224 | -------------------------------------------------------------------------------- /services_delegate_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/NinesStack/sidecar/catalog" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func Test_GetBroadcasts(t *testing.T) { 11 | Convey("When handing back broadcast messages", t, func() { 12 | state := catalog.NewServicesState() 13 | delegate := NewServicesDelegate(state) 14 | bCast := [][]byte{ 15 | []byte(`{"ID":"d419fa7ad1a7","Name":"/dockercon-6adfe629eebc91","Image":"nginx:latest","Created":"2015-02-25T19:04:46Z","Hostname":"docker2","Ports":[{"Type":"tcp","Port":10234}],"Updated":"2015-03-04T01:12:46.669648453Z","Status":0}`), 16 | []byte(`{"ID":"deadbeefabba","Name":"/dockercon-6c01869525db08","Image":"nginx:latest","Created":"2015-02-25T19:04:46Z","Hostname":"docker2","Ports":[{"Type":"tcp","Port":10234}],"Updated":"2015-03-04T01:12:46.669648453Z","Status":0}`), 17 | } 18 | bCast2 := [][]byte{ 19 | []byte(`{"ID":"1b3295bf300f","Name":"/romantic_brown","Image":"0415448f2cc2","Created":"2014-10-02T23:58:48Z","Hostname":"docker1","Ports":[{"Type":"tcp","Port":9494}],"Updated":"2015-03-04T01:12:32.630357657Z","Status":0}`), 20 | []byte(`{"ID":"deadbeefabba","Name":"/dockercon-6c01869525db08","Image":"nginx:latest","Created":"2015-02-25T19:04:46Z","Hostname":"docker2","Ports":[{"Type":"tcp","Port":10234}],"Updated":"2015-03-04T01:12:46.669648453Z","Status":0}`), 21 | } 22 | 23 | Convey("Start()", func() { 24 | Convey("Kicks off goroutine", func() { 25 | So(delegate.Started, ShouldBeFalse) 26 | delegate.Start() 27 | So(delegate.Started, ShouldBeTrue) 28 | }) 29 | }) 30 | 31 | Convey("NotifyMsg()", func() { 32 | Convey("Pushes a message into the channel", func() { 33 | delegate.NotifyMsg(bCast[0]) 34 | msg := <-delegate.notifications 35 | So(msg, ShouldNotBeNil) 36 | So(msg, ShouldResemble, bCast[0]) 37 | }) 38 | }) 39 | 40 | Convey("GetBroadcasts()", func() { 41 | Convey("Returns nil when there is nothing to send", func() { 42 | So(delegate.GetBroadcasts(3, 1398), ShouldBeNil) 43 | }) 44 | 45 | Convey("Returns from the pending list when nothing in the channel", func() { 46 | data := []byte("data") 47 | delegate.pendingBroadcasts = [][]byte{data} 48 | 49 | result := delegate.GetBroadcasts(3, 1398) 50 | So(string(result[0]), ShouldEqual, string(data)) 51 | So(len(result), ShouldEqual, 1) 52 | }) 53 | 54 | Convey("Returns what's in the channel", func() { 55 | state.Broadcasts = make(chan [][]byte, 1) 56 | state.Broadcasts <- bCast 57 | result := delegate.GetBroadcasts(3, 1398) 58 | 59 | So(len(result), ShouldEqual, 2) 60 | So(string(result[0]), ShouldEqual, string(bCast[0])) 61 | So(string(result[1]), ShouldEqual, string(bCast[1])) 62 | So(len(delegate.pendingBroadcasts), ShouldEqual, 0) 63 | }) 64 | 65 | Convey("Returns what's left when nothing is new", func() { 66 | delegate.pendingBroadcasts = bCast 67 | 68 | result := delegate.GetBroadcasts(3, 1398) 69 | So(len(result), ShouldEqual, 2) 70 | So(string(result[0]), ShouldEqual, string(bCast[0])) 71 | So(string(result[1]), ShouldEqual, string(bCast[1])) 72 | So(len(delegate.pendingBroadcasts), ShouldEqual, 0) 73 | }) 74 | 75 | Convey("Returns what's left and what's new when it fits", func() { 76 | state.Broadcasts = make(chan [][]byte, 1) 77 | delegate.pendingBroadcasts = bCast 78 | state.Broadcasts <- bCast2 79 | 80 | result := delegate.GetBroadcasts(3, 1398) 81 | So(len(result), ShouldEqual, 4) 82 | for i, entry := range append(bCast2, bCast...) { 83 | So(string(result[i]), ShouldEqual, string(entry)) 84 | } 85 | So(len(delegate.pendingBroadcasts), ShouldEqual, 0) 86 | }) 87 | 88 | Convey("Many runs with leftovers don't leave junk or bad buffers", func() { 89 | state.Broadcasts = make(chan [][]byte, 1) 90 | delegate.pendingBroadcasts = bCast 91 | state.Broadcasts <- append(bCast2, bCast...) 92 | 93 | delegate.GetBroadcasts(3, 100) 94 | delegate.GetBroadcasts(3, 300) // 1 message fits here 95 | delegate.GetBroadcasts(3, 100) 96 | 97 | result := delegate.GetBroadcasts(3, 1398) 98 | So(len(result), ShouldEqual, 5) 99 | for i, entry := range append(bCast2[1:], bCast...) { 100 | So(string(result[i]), ShouldEqual, string(entry)) 101 | } 102 | So(len(delegate.pendingBroadcasts), ShouldEqual, 0) 103 | }) 104 | }) 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /sidecarhttp/envoy_api_test.go: -------------------------------------------------------------------------------- 1 | package sidecarhttp 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/NinesStack/sidecar/catalog" 10 | "github.com/NinesStack/sidecar/service" 11 | . "github.com/smartystreets/goconvey/convey" 12 | ) 13 | 14 | var ( 15 | hostname = "chaucer" 16 | state = catalog.NewServicesState() 17 | 18 | baseTime = time.Now().UTC() 19 | 20 | svcId = "deadbeef123" 21 | svcId2 = "deadbeef456" 22 | svcId3 = "deadbeef666" 23 | 24 | svc = service.Service{ 25 | ID: svcId, 26 | Name: "bocaccio", 27 | Image: "101deadbeef", 28 | Created: baseTime, 29 | Hostname: hostname, 30 | Updated: baseTime, 31 | Status: service.ALIVE, 32 | Ports: []service.Port{ 33 | {IP: "127.0.0.1", Port: 9999, ServicePort: 10100}, 34 | }, 35 | } 36 | 37 | svc2 = service.Service{ 38 | ID: svcId2, 39 | Name: "shakespeare", 40 | Image: "202deadbeef", 41 | Created: baseTime, 42 | Hostname: hostname, 43 | Updated: baseTime, 44 | Status: service.UNHEALTHY, 45 | Ports: []service.Port{ 46 | {IP: "127.0.0.1", Port: 9000, ServicePort: 10111}, 47 | }, 48 | } 49 | 50 | svc3 = service.Service{ 51 | ID: svcId3, 52 | Name: "dante", 53 | Image: "666deadbeef", 54 | Created: baseTime, 55 | Hostname: hostname, 56 | Updated: baseTime, 57 | Status: service.ALIVE, 58 | } 59 | ) 60 | 61 | func Test_clustersHandler(t *testing.T) { 62 | Convey("clustersHandler()", t, func() { 63 | state.AddServiceEntry(svc) 64 | state.AddServiceEntry(svc2) 65 | state.AddServiceEntry(svc3) 66 | 67 | req := httptest.NewRequest("GET", "/clusters", nil) 68 | recorder := httptest.NewRecorder() 69 | 70 | bindIP := "192.168.168.168" 71 | 72 | api := &EnvoyApi{state: state, config: &HttpConfig{BindIP: bindIP}} 73 | 74 | Convey("returns information for alive services", func() { 75 | api.clustersHandler(recorder, req, nil) 76 | status, _, body := getResult(recorder) 77 | 78 | So(status, ShouldEqual, 200) 79 | So(body, ShouldContainSubstring, "bocaccio") 80 | }) 81 | 82 | Convey("does not include unhealthy services", func() { 83 | api.clustersHandler(recorder, req, nil) 84 | status, _, body := getResult(recorder) 85 | 86 | So(status, ShouldEqual, 200) 87 | So(body, ShouldNotContainSubstring, "shakespeare") 88 | }) 89 | 90 | Convey("does not include services without a ServicePort", func() { 91 | api.clustersHandler(recorder, req, nil) 92 | status, _, body := getResult(recorder) 93 | 94 | So(status, ShouldEqual, 200) 95 | So(body, ShouldNotContainSubstring, "dante") 96 | }) 97 | 98 | Convey("returns empty clusters for empty state", func() { 99 | api := &EnvoyApi{state: catalog.NewServicesState(), config: &HttpConfig{BindIP: bindIP}} 100 | api.clustersHandler(recorder, req, nil) 101 | status, _, body := getResult(recorder) 102 | 103 | So(status, ShouldEqual, 200) 104 | 105 | var cdsResult CDSResult 106 | err := json.Unmarshal([]byte(body), &cdsResult) 107 | So(err, ShouldBeNil) 108 | So(cdsResult.Clusters, ShouldNotBeNil) 109 | So(cdsResult.Clusters, ShouldBeEmpty) 110 | }) 111 | }) 112 | } 113 | 114 | func Test_registrationHandler(t *testing.T) { 115 | Convey("registrationHandler()", t, func() { 116 | state.AddServiceEntry(svc) 117 | state.AddServiceEntry(svc2) 118 | state.AddServiceEntry(svc3) 119 | 120 | recorder := httptest.NewRecorder() 121 | 122 | bindIP := "192.168.168.168" 123 | 124 | api := &EnvoyApi{state: state, config: &HttpConfig{BindIP: bindIP}} 125 | 126 | Convey("returns an error unless a service is provided", func() { 127 | req := httptest.NewRequest("GET", "/registration/", nil) 128 | api.registrationHandler(recorder, req, nil) 129 | status, _, _ := getResult(recorder) 130 | 131 | So(status, ShouldEqual, 404) 132 | }) 133 | 134 | Convey("returns an error unless port is appended", func() { 135 | req := httptest.NewRequest("GET", "/registration/", nil) 136 | params := map[string]string{ 137 | "service": "bocaccio", 138 | } 139 | api.registrationHandler(recorder, req, params) 140 | status, _, _ := getResult(recorder) 141 | 142 | So(status, ShouldEqual, 404) 143 | }) 144 | 145 | Convey("returns information for alive services", func() { 146 | req := httptest.NewRequest("GET", "/registration/bocaccio:10100", nil) 147 | params := map[string]string{ 148 | "service": "bocaccio:10100", 149 | } 150 | api.registrationHandler(recorder, req, params) 151 | status, _, body := getResult(recorder) 152 | 153 | So(status, ShouldEqual, 200) 154 | So(body, ShouldContainSubstring, "bocaccio") 155 | }) 156 | 157 | Convey("does not include services without a ServicePort", func() { 158 | req := httptest.NewRequest("GET", "/registration/dante:12323", nil) 159 | params := map[string]string{ 160 | "service": "dante:12323", 161 | } 162 | api.registrationHandler(recorder, req, params) 163 | status, _, body := getResult(recorder) 164 | 165 | So(status, ShouldEqual, 200) 166 | 167 | var sdsResult SDSResult 168 | err := json.Unmarshal([]byte(body), &sdsResult) 169 | So(err, ShouldBeNil) 170 | So(sdsResult.Env, ShouldEqual, "") 171 | So(sdsResult.Hosts, ShouldNotBeNil) 172 | So(sdsResult.Hosts, ShouldBeEmpty) 173 | So(sdsResult.Service, ShouldEqual, "dante:12323") 174 | }) 175 | 176 | Convey("does not include unhealthy services", func() { 177 | req := httptest.NewRequest("GET", "/registration/shakespeare:10111", nil) 178 | params := map[string]string{ 179 | "service": "shakespeare:10111", 180 | } 181 | api.registrationHandler(recorder, req, params) 182 | status, _, body := getResult(recorder) 183 | 184 | So(status, ShouldEqual, 200) 185 | var sdsResult SDSResult 186 | err := json.Unmarshal([]byte(body), &sdsResult) 187 | So(err, ShouldBeNil) 188 | So(sdsResult.Env, ShouldEqual, "") 189 | So(sdsResult.Hosts, ShouldNotBeNil) 190 | So(sdsResult.Hosts, ShouldBeEmpty) 191 | So(sdsResult.Service, ShouldEqual, "shakespeare:10111") 192 | }) 193 | }) 194 | } 195 | 196 | func Test_listenersHandler(t *testing.T) { 197 | Convey("listenersHandler()", t, func() { 198 | state.AddServiceEntry(svc) 199 | state.AddServiceEntry(svc2) 200 | state.AddServiceEntry(svc3) 201 | 202 | recorder := httptest.NewRecorder() 203 | 204 | bindIP := "192.168.168.168" 205 | 206 | api := &EnvoyApi{state: state, config: &HttpConfig{BindIP: bindIP}} 207 | req := httptest.NewRequest("GET", "/listeners/", nil) 208 | 209 | Convey("returns listeners for alive services", func() { 210 | api.listenersHandler(recorder, req, nil) 211 | status, _, body := getResult(recorder) 212 | 213 | So(status, ShouldEqual, 200) 214 | So(body, ShouldContainSubstring, "bocaccio") 215 | }) 216 | 217 | Convey("doesn't return listeners for unhealthy services", func() { 218 | api.listenersHandler(recorder, req, nil) 219 | status, _, body := getResult(recorder) 220 | 221 | So(status, ShouldEqual, 200) 222 | So(body, ShouldNotContainSubstring, "shakespeare") 223 | }) 224 | 225 | Convey("returns empty listeners for empty state", func() { 226 | api := &EnvoyApi{state: catalog.NewServicesState(), config: &HttpConfig{BindIP: bindIP}} 227 | api.listenersHandler(recorder, req, nil) 228 | status, _, body := getResult(recorder) 229 | 230 | So(status, ShouldEqual, 200) 231 | 232 | var ldsResult LDSResult 233 | err := json.Unmarshal([]byte(body), &ldsResult) 234 | So(err, ShouldBeNil) 235 | So(ldsResult.Listeners, ShouldNotBeNil) 236 | So(ldsResult.Listeners, ShouldBeEmpty) 237 | }) 238 | 239 | Convey("supports TCP proxy mode", func() { 240 | state.AddServiceEntry(service.Service{ 241 | ID: "deadbeef789", 242 | Name: "chaucer", 243 | Image: "101deadbeef", 244 | Created: baseTime, 245 | Hostname: hostname, 246 | Updated: baseTime, 247 | Status: service.ALIVE, 248 | Ports: []service.Port{ 249 | {IP: "127.0.0.1", Port: 9999, ServicePort: 10122}, 250 | }, 251 | ProxyMode: "http", 252 | }) 253 | 254 | api.listenersHandler(recorder, req, nil) 255 | status, _, body := getResult(recorder) 256 | 257 | So(status, ShouldEqual, 200) 258 | 259 | var ldsResult LDSResult 260 | err := json.Unmarshal([]byte(body), &ldsResult) 261 | So(err, ShouldBeNil) 262 | So(ldsResult.Listeners, ShouldNotBeNil) 263 | 264 | So(len(ldsResult.Listeners), ShouldEqual, 2) 265 | 266 | var httpListener *EnvoyListener 267 | var tcpListener *EnvoyListener 268 | for _, l := range ldsResult.Listeners { 269 | if l.Name == "chaucer:10122" { 270 | httpListener = l 271 | } else if l.Name == "bocaccio:10100" { 272 | tcpListener = l 273 | } 274 | } 275 | 276 | So(httpListener, ShouldNotBeNil) 277 | So(len(httpListener.Filters), ShouldEqual, 1) 278 | So(httpListener.Filters[0].Name, ShouldEqual, "envoy.http_connection_manager") 279 | 280 | So(tcpListener, ShouldNotBeNil) 281 | So(len(tcpListener.Filters), ShouldEqual, 1) 282 | So(tcpListener.Filters[0].Name, ShouldEqual, "envoy.tcp_proxy") 283 | So(tcpListener.Filters[0].Config, ShouldNotBeNil) 284 | So(tcpListener.Filters[0].Config.RouteConfig, ShouldNotBeNil) 285 | So(len(tcpListener.Filters[0].Config.RouteConfig.Routes), ShouldEqual, 1) 286 | So(tcpListener.Filters[0].Config.RouteConfig.Routes[0].Cluster, ShouldEqual, "bocaccio:10100") 287 | 288 | }) 289 | }) 290 | } 291 | -------------------------------------------------------------------------------- /sidecarhttp/http.go: -------------------------------------------------------------------------------- 1 | package sidecarhttp 2 | 3 | import ( 4 | "net/http" 5 | _ "net/http/pprof" 6 | "time" 7 | 8 | "github.com/NinesStack/memberlist" 9 | "github.com/NinesStack/sidecar/catalog" 10 | "github.com/gorilla/mux" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type HttpConfig struct { 15 | BindIP string 16 | UseHostnames bool 17 | } 18 | 19 | func makeHandler(fn func(http.ResponseWriter, *http.Request, 20 | *memberlist.Memberlist, *catalog.ServicesState, map[string]string), 21 | list *memberlist.Memberlist, state *catalog.ServicesState) http.HandlerFunc { 22 | 23 | return func(response http.ResponseWriter, req *http.Request) { 24 | fn(response, req, list, state, mux.Vars(req)) 25 | } 26 | } 27 | 28 | func serversHandler(response http.ResponseWriter, req *http.Request, list *memberlist.Memberlist, state *catalog.ServicesState, params map[string]string) { 29 | defer req.Body.Close() 30 | 31 | response.Header().Set("Content-Type", "text/html") 32 | state.RLock() 33 | defer state.RUnlock() 34 | 35 | _, err := response.Write( 36 | []byte(` 37 | 38 | 39 | 40 |
` + state.Format(list) + "
")) 41 | 42 | if err != nil { 43 | log.Errorf("Error writing servers response to client: %s", err) 44 | } 45 | } 46 | 47 | type Member struct { 48 | Node *memberlist.Node 49 | Updated time.Time 50 | } 51 | 52 | func uiRedirectHandler(response http.ResponseWriter, req *http.Request) { 53 | http.Redirect(response, req, "/ui/", 301) 54 | } 55 | 56 | func ServeHttp(list *memberlist.Memberlist, state *catalog.ServicesState, config *HttpConfig) { 57 | srvrsHandle := makeHandler(serversHandler, list, state) 58 | staticFs := http.FileServer(http.Dir("views/static")) 59 | uiFs := http.FileServer(http.Dir("ui/app")) 60 | 61 | api := &SidecarApi{state: state, list: list} 62 | envoyApi := &EnvoyApi{state: state, list: list, config: config} 63 | 64 | router := mux.NewRouter() 65 | router.HandleFunc("/", uiRedirectHandler).Methods("GET") 66 | router.HandleFunc("/servers", srvrsHandle).Methods("GET") 67 | router.PathPrefix("/static").Handler(http.StripPrefix("/static", staticFs)) 68 | router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", uiFs)) 69 | router.PathPrefix("/api").Handler(http.StripPrefix("/api", api.HttpMux())) 70 | router.PathPrefix("/v1").Handler(http.StripPrefix("/v1", envoyApi.HttpMux())) 71 | 72 | // DEPRECATED - to be removed once common clients are updated 73 | router.HandleFunc("/services.{extension}", wrap(api.servicesHandler)).Methods("GET") 74 | router.HandleFunc("/state.{extension}", wrap(api.stateHandler)).Methods("GET") 75 | router.HandleFunc("/watch", wrap(api.watchHandler)).Methods("GET") 76 | // ------------------------------------------------------------ 77 | 78 | http.Handle("/", router) 79 | 80 | err := http.ListenAndServe("0.0.0.0:7777", nil) 81 | if err != nil { 82 | log.Fatalf("Can't start HTTP server: %s", err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /sidecarhttp/http_listener.go: -------------------------------------------------------------------------------- 1 | package sidecarhttp 2 | 3 | import ( 4 | "fmt" 5 | _ "net/http/pprof" 6 | "time" 7 | 8 | "github.com/NinesStack/sidecar/catalog" 9 | ) 10 | 11 | // A ServicesState.Listener that we use for the /watch endpoint 12 | type HttpListener struct { 13 | eventChan chan catalog.ChangeEvent 14 | name string 15 | } 16 | 17 | func NewHttpListener() *HttpListener { 18 | return &HttpListener{ 19 | // This should be fine enough granularity for practical purposes 20 | name: fmt.Sprintf("httpListener-%d", time.Now().UTC().UnixNano()), 21 | // Listeners must have buffered channels. We'll use a 22 | // somewhat larger buffer here because of the slow link 23 | // problem with http 24 | eventChan: make(chan catalog.ChangeEvent, 50), 25 | } 26 | } 27 | 28 | func (h *HttpListener) Chan() chan catalog.ChangeEvent { 29 | return h.eventChan 30 | } 31 | 32 | func (h *HttpListener) Name() string { 33 | return h.name 34 | } 35 | 36 | func (h *HttpListener) Managed() bool { 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /sidecarhttp/http_test.go: -------------------------------------------------------------------------------- 1 | package sidecarhttp 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | ) 8 | 9 | // getResult fetches the status code, headers, and body from a recorder 10 | func getResult(recorder *httptest.ResponseRecorder) (code int, headers *http.Header, body string) { 11 | resp := recorder.Result() 12 | bodyBytes, _ := ioutil.ReadAll(resp.Body) 13 | body = string(bodyBytes) 14 | 15 | return resp.StatusCode, &resp.Header, body 16 | } 17 | -------------------------------------------------------------------------------- /ui/app/Sidecar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/ui/app/Sidecar.png -------------------------------------------------------------------------------- /ui/app/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Declare app level module which depends on views, and components 4 | angular.module('sidecar', [ 5 | 'ngRoute', 6 | 'sidecar.services', 7 | // 'sidecar.version' 8 | ]). 9 | config(['$locationProvider', '$routeProvider', function($locationProvider, $routeProvider) { 10 | $locationProvider.hashPrefix('!'); 11 | 12 | $routeProvider.otherwise({redirectTo: '/services'}); 13 | }]); 14 | -------------------------------------------------------------------------------- /ui/app/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-seed", 3 | "description": "A starter project for AngularJS", 4 | "version": "0.0.0", 5 | "homepage": "https://github.com/angular/angular-seed", 6 | "license": "MIT", 7 | "private": true, 8 | "dependencies": { 9 | "angular": "~1.5.0", 10 | "angular-route": "~1.5.0", 11 | "angular-loader": "~1.5.0", 12 | "angular-mocks": "~1.5.0", 13 | "angular-bootstrap": "~1.3.0", 14 | "html5-boilerplate": "^5.3.0", 15 | "underscore": "1.8.3", 16 | "papaparse": "4.1.2", 17 | "bootswatch-dist": "superhero" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/app/components/version/interpolate-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('myApp.version.interpolate-filter', []) 4 | 5 | .filter('interpolate', ['version', function(version) { 6 | return function(text) { 7 | return String(text).replace(/\%VERSION\%/mg, version); 8 | }; 9 | }]); 10 | -------------------------------------------------------------------------------- /ui/app/components/version/interpolate-filter_test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('myApp.version module', function() { 4 | beforeEach(module('myApp.version')); 5 | 6 | describe('interpolate filter', function() { 7 | beforeEach(module(function($provide) { 8 | $provide.value('version', 'TEST_VER'); 9 | })); 10 | 11 | it('should replace VERSION', inject(function(interpolateFilter) { 12 | expect(interpolateFilter('before %VERSION% after')).toEqual('before TEST_VER after'); 13 | })); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /ui/app/components/version/version-directive.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('myApp.version.version-directive', []) 4 | 5 | .directive('appVersion', ['version', function(version) { 6 | return function(scope, elm, attrs) { 7 | elm.text(version); 8 | }; 9 | }]); 10 | -------------------------------------------------------------------------------- /ui/app/components/version/version-directive_test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('myApp.version module', function() { 4 | beforeEach(module('myApp.version')); 5 | 6 | describe('app-version directive', function() { 7 | it('should print current version', function() { 8 | module(function($provide) { 9 | $provide.value('version', 'TEST_VER'); 10 | }); 11 | inject(function($compile, $rootScope) { 12 | var element = $compile('')($rootScope); 13 | expect(element.text()).toEqual('TEST_VER'); 14 | }); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /ui/app/components/version/version.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('myApp.version', [ 4 | 'myApp.version.interpolate-filter', 5 | 'myApp.version.version-directive' 6 | ]) 7 | 8 | .value('version', '0.1'); 9 | -------------------------------------------------------------------------------- /ui/app/components/version/version_test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('myApp.version module', function() { 4 | beforeEach(module('myApp.version')); 5 | 6 | describe('version service', function() { 7 | it('should return current version', inject(function(version) { 8 | expect(version).toEqual('0.1'); 9 | })); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /ui/app/css/services.css: -------------------------------------------------------------------------------- 1 | .panel-heading { 2 | padding: 5px 15px; 3 | } 4 | h4 { 5 | margin-top: 5px; 6 | margin-bottom: 5px; 7 | } 8 | .table { 9 | margin-bottom: 5px; 10 | } 11 | body { 12 | width: 100%; 13 | } 14 | table { 15 | table-layout: fixed; 16 | } 17 | tr { 18 | width: 100%; 19 | } 20 | .hostname { 21 | font-weight: bold; 22 | font-size: large; 23 | } 24 | #hostname { 25 | width: 30%; 26 | } 27 | .group-row { 28 | background-color: #789; 29 | font-size: 14px; 30 | } 31 | .group-row > td { 32 | vertical-align: middle !important; 33 | } 34 | .group-row .service-json-tooltip { 35 | display: none; 36 | position: absolute; 37 | background-color: #f5deb3; 38 | z-index: 1; 39 | border: 1px solid black; 40 | top: 0px; 41 | } 42 | .group-row .service-json-tooltip.expanded { 43 | display: block; 44 | } 45 | .group-row .service-json-tooltip.expanded .glyphicon-remove { 46 | position: absolute; 47 | font-size: 22px; 48 | width: 22px; 49 | top: 2px; 50 | right: 4px; 51 | cursor: pointer; 52 | } 53 | .service-info-icon { 54 | font-size: 23px; 55 | padding: 0 0 0 5px; 56 | cursor: pointer; 57 | } 58 | .service-badge { 59 | float: left; 60 | font-size: 18px; 61 | border-radius: 20px 62 | } 63 | .btn-info { 64 | padding: 2px 6px; 65 | } -------------------------------------------------------------------------------- /ui/app/index-async.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | 44 | My AngularJS App 45 | 46 | 47 | 48 | 52 | 53 |
54 | 55 |
Angular seed app: v
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /ui/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Sidecar 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 |
20 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /ui/app/services/services.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 |
13 |
14 |

Cluster - {{ clusterName }}

15 |
16 | 17 | 18 | 26 | 29 | 32 | 35 | 36 |
19 | 20 | 21 | 22 |  {{ hostname }} 23 | 24 | 25 | 27 | {{ contents.LastUpdated | timeAgo }} 28 | 30 | {{ contents.ServiceCount == 0 ? 'no' : contents.ServiceCount }} services 31 | 33 | haproxy 34 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |

45 |   46 | {{ svcName }} 47 |

48 |

49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 60 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
VersionPortsCreatedUpdatedStatus
61 | {{ group.length }} 62 | 66 | 67 |
{{ group[0] | prettyJSON }}
74 |
{{ group[0].Image | extractTag }}{{ group[0].Ports | portsStr }}{{ group[0].Created | timeAgo }}{{ group[0].Updated | timeAgo }}{{ group[0].Status | statusStr }}
82 | 83 | 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 94 | 95 | 96 | 97 | 98 | 99 | 106 | 107 |
HostnameVersionPortsCreatedUpdatedStatus
{{ svc.Hostname }}{{ svc.Image | extractTag }}{{ svc.Ports | portsStr }}{{ svc.Created | timeAgo }}{{ svc.Updated | timeAgo }} 100 | {{ svc.Status | statusStr }} 101 |   105 |
108 |
109 | 110 |
111 |
112 |
113 | 114 | -------------------------------------------------------------------------------- /ui/app/services/services.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('sidecar.services', ['ngRoute', 'ui.bootstrap']) 4 | 5 | .config(['$routeProvider', function($routeProvider) { 6 | $routeProvider.when('/services', { 7 | templateUrl: 'services/services.html', 8 | controller: 'servicesCtrl' 9 | }); 10 | }]) 11 | 12 | .factory('stateService', function($http, $q) { 13 | function svcGetServices() { 14 | return $http({ 15 | method: 'GET', 16 | url: '/api/services.json', 17 | dataType: 'json', 18 | }); 19 | }; 20 | 21 | var haproxyUrl = window.location.protocol + 22 | '//' + window.location.hostname + 23 | ':3212/;csv;norefresh'; 24 | 25 | function svcGetHaproxy() { 26 | return $http({ 27 | method: 'GET', 28 | url: haproxyUrl, 29 | dataType: 'text/plain', 30 | timeout: 300 31 | }); 32 | }; 33 | 34 | var serviceWaiter = $q.defer(); 35 | var haproxyWaiter = $q.defer(); 36 | 37 | var state = { 38 | waitFirstServices: serviceWaiter.promise, 39 | waitFirstHaproxy: haproxyWaiter.promise, 40 | services: {}, 41 | haproxy: {}, 42 | 43 | getServices: function() { 44 | return state.services; 45 | }, 46 | 47 | getHaproxy: function() { 48 | return state.haproxy; 49 | } 50 | }; 51 | 52 | // Called on an interval to keep the data up to date 53 | function refreshData() { 54 | svcGetServices().then(function(services) { 55 | state.services = services.data; 56 | serviceWaiter.resolve(); 57 | }); 58 | 59 | svcGetHaproxy().then(function(haproxy) { 60 | state.haproxy = haproxy.data; 61 | haproxyWaiter.resolve(); 62 | }).catch(function() { 63 | // didn't get a valid response, maybe we're not running HAproxy 64 | haproxyWaiter.resolve(); 65 | }); 66 | }; 67 | 68 | setInterval(refreshData, 4000); // every 4 seconds 69 | 70 | return state; 71 | }) 72 | 73 | .controller('servicesCtrl', function($scope, $interval, stateService) { 74 | $scope.serverList = {}; 75 | $scope.clusterName = ""; 76 | $scope.servicesList = {}; 77 | $scope.collapsed = {}; 78 | $scope.expandedServiceInfo = {}; 79 | $scope.haproxyInfo = {}; 80 | 81 | $scope.toggleCollapse = function(svcName) { 82 | $scope.collapsed[svcName] = !$scope.isCollapsed(svcName); 83 | }; 84 | 85 | $scope.isCollapsed = function(svcName) { 86 | return $scope.collapsed[svcName] == null || $scope.collapsed[svcName]; 87 | }; 88 | 89 | $scope.toggleServiceInfo = function(svcName) { 90 | $scope.expandedServiceInfo[svcName] = !$scope.isExpandedServiceInfo(svcName); 91 | }; 92 | 93 | $scope.isExpandedServiceInfo = function(svcName) { 94 | return $scope.expandedServiceInfo[svcName]; 95 | }; 96 | 97 | $scope.haproxyHas = function(svc) { 98 | if ($scope.haproxyInfo[svc.Name] == null) return false; 99 | if ($scope.haproxyInfo[svc.Name][svc.Hostname] == null) return false; 100 | if ($scope.haproxyInfo[svc.Name][svc.Hostname][svc.ID] == null) return false; 101 | 102 | return true; 103 | }; 104 | 105 | function updateData() { 106 | // Services 107 | var services = {}; 108 | var servicesResponse = stateService.getServices(); 109 | 110 | for (var svcName in servicesResponse.Services) { 111 | services[svcName] = servicesResponse.Services[svcName].groupBy(function(s) { 112 | var ports = _.map(s.Ports, function(p) { _.pick(p, 'ServicePort') }); 113 | return [s.Image, ports, s.Status]; 114 | }); 115 | if ($scope.collapsed[svcName] == null) { 116 | $scope.collapsed[svcName] = true; 117 | } 118 | } 119 | $scope.servicesList = services; 120 | 121 | $scope.clusterName = servicesResponse.ClusterName; 122 | $scope.serverList = servicesResponse.ClusterMembers; 123 | 124 | // Haproxy 125 | var haproxyResponse = stateService.getHaproxy(); 126 | var raw = {}; 127 | try { 128 | raw = Papa.parse(haproxyResponse, { header: true }); 129 | } catch(e) { 130 | console.log("Appears there is no HAproxy, skipping") 131 | return; 132 | } 133 | 134 | var transform = function(memo, item) { 135 | if (item.svname == 'FRONTEND' || item.svname == 'BACKEND' || 136 | item['# pxname'] == 'stats' || item['# pxname'] == 'stats_proxy' || 137 | item['# pxname'] == '') { 138 | return memo 139 | } 140 | 141 | // Transform the resulting HAproxy structure into something we can use 142 | var fields = item['# pxname'].split('-'); 143 | var svcPort = fields[fields.length-1]; 144 | var svcName = fields.slice(0, fields.length-1).join('-'); 145 | 146 | fields = item.svname.split('-'); 147 | var hostname = fields.slice(0, fields.length-1).join('-'); 148 | var containerID = fields[fields.length-1]; 149 | 150 | // Store by servce -> hostname -> container 151 | memo[svcName] = memo[svcName] || {}; 152 | memo[svcName][hostname] = memo[svcName][hostname] || {} 153 | memo[svcName][hostname][containerID] = item; 154 | 155 | return memo 156 | }; 157 | 158 | var processed = _.inject(raw.data, transform, {}); 159 | $scope.haproxyInfo = processed; 160 | }; 161 | 162 | // On the first time through, this will update the data and kick off the 163 | // scheduled refresh. Otherwise do nothing. 164 | stateService.waitFirstHaproxy.then(function() { 165 | stateService.waitFirstServices.then(function() { 166 | updateData(); 167 | $interval(updateData, 4000); // Update UI every 2 seconds 168 | }, function(){}) 169 | }, function(){}); 170 | }) 171 | 172 | .filter('portsStr', function() { 173 | return function(svcPorts) { 174 | var ports = [] 175 | for (var i in svcPorts) { 176 | var port = svcPorts[i] 177 | 178 | if (port.Port == null) { 179 | continue; 180 | } 181 | 182 | if (port.ServicePort != null && port.ServicePort != 0) { 183 | ports.push(port.ServicePort.toString() + "->" + port.Port.toString()) 184 | } else { 185 | ports.push(port.Port.toString()) 186 | } 187 | } 188 | 189 | return ports.join(", ") 190 | }; 191 | }) 192 | 193 | .filter('statusStr', function() { 194 | return function(status) { 195 | switch (status) { 196 | case 0: 197 | return "Alive" 198 | case 2: 199 | return "Unhealthy" 200 | case 3: 201 | return "Unknown" 202 | case 4: 203 | return "Draining" 204 | default: 205 | return "Tombstone" 206 | } 207 | } 208 | }) 209 | 210 | .filter('timeAgo', function() { 211 | return function(textDate) { 212 | if (textDate == null || textDate == "") { 213 | return "never"; 214 | } 215 | 216 | var date = Date.parse(textDate) 217 | var seconds = Math.floor((new Date() - date) / 1000); 218 | 219 | // Filter things which aren't quite Unix epoch zero but are still bogus 220 | if (seconds > 630720000) { // More than 20 years ago 221 | return "never"; 222 | } 223 | 224 | var interval = Math.floor(seconds / 31536000); 225 | 226 | if (interval > 1) { 227 | return interval + " years ago"; 228 | } 229 | interval = Math.floor(seconds / 2592000); 230 | if (interval > 1) { 231 | return interval + " months ago"; 232 | } 233 | interval = Math.floor(seconds / 86400); 234 | if (interval > 1) { 235 | return interval + " days ago"; 236 | } 237 | interval = Math.floor(seconds / 3600); 238 | if (interval > 1) { 239 | return interval + " hours ago"; 240 | } 241 | interval = Math.floor(seconds / 60); 242 | if (interval > 1) { 243 | return interval + " mins ago"; 244 | } 245 | return Math.floor(seconds) + " secs ago"; 246 | } 247 | }) 248 | 249 | .filter('imageStr', function() { 250 | return function(imageName) { 251 | if (imageName.length < 25) { 252 | return imageName; 253 | } 254 | 255 | return imageName.substr(imageName.length - 25, imageName.length) 256 | } 257 | }) 258 | 259 | .filter('extractTag', function() { 260 | return function(imageName) { 261 | return imageName.split(':')[1] 262 | } 263 | }) 264 | 265 | .filter('prettyJSON', function() { 266 | return function(obj) { 267 | return JSON ? JSON.stringify(obj, null, 2) : obj; 268 | } 269 | }) 270 | 271 | ; 272 | 273 | if ( ! Array.prototype.groupBy) { 274 | Array.prototype.groupBy = function (f) 275 | { 276 | var groups = {}; 277 | this.forEach(function(o) { 278 | var group = JSON.stringify(f(o)); 279 | groups[group] = groups[group] || []; 280 | groups[group].push(o); 281 | }); 282 | 283 | return Object.keys(groups).map(function (group) { 284 | return groups[group]; 285 | }); 286 | }; 287 | } 288 | -------------------------------------------------------------------------------- /ui/e2e-tests/protractor.conf.js: -------------------------------------------------------------------------------- 1 | //jshint strict: false 2 | exports.config = { 3 | 4 | allScriptsTimeout: 11000, 5 | 6 | specs: [ 7 | '*.js' 8 | ], 9 | 10 | capabilities: { 11 | 'browserName': 'chrome' 12 | }, 13 | 14 | baseUrl: 'http://localhost:8000/', 15 | 16 | framework: 'jasmine', 17 | 18 | jasmineNodeOpts: { 19 | defaultTimeoutInterval: 30000 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /ui/e2e-tests/scenarios.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* https://github.com/angular/protractor/blob/master/docs/toc.md */ 4 | 5 | describe('my app', function() { 6 | 7 | 8 | it('should automatically redirect to /view1 when location hash/fragment is empty', function() { 9 | browser.get('index.html'); 10 | expect(browser.getLocationAbsUrl()).toMatch("/view1"); 11 | }); 12 | 13 | 14 | describe('view1', function() { 15 | 16 | beforeEach(function() { 17 | browser.get('index.html#!/view1'); 18 | }); 19 | 20 | 21 | it('should render view1 when user navigates to /view1', function() { 22 | expect(element.all(by.css('[ng-view] p')).first().getText()). 23 | toMatch(/partial for view 1/); 24 | }); 25 | 26 | }); 27 | 28 | 29 | describe('view2', function() { 30 | 31 | beforeEach(function() { 32 | browser.get('index.html#!/view2'); 33 | }); 34 | 35 | 36 | it('should render view2 when user navigates to /view2', function() { 37 | expect(element.all(by.css('[ng-view] p')).first().getText()). 38 | toMatch(/partial for view 2/); 39 | }); 40 | 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /ui/karma.conf.js: -------------------------------------------------------------------------------- 1 | //jshint strict: false 2 | module.exports = function(config) { 3 | config.set({ 4 | 5 | basePath: './app', 6 | 7 | files: [ 8 | 'bower_components/angular/angular.js', 9 | 'bower_components/angular-route/angular-route.js', 10 | 'bower_components/angular-mocks/angular-mocks.js', 11 | 'components/**/*.js', 12 | 'view*/**/*.js' 13 | ], 14 | 15 | autoWatch: true, 16 | 17 | frameworks: ['jasmine'], 18 | 19 | browsers: ['Chrome'], 20 | 21 | plugins: [ 22 | 'karma-chrome-launcher', 23 | 'karma-firefox-launcher', 24 | 'karma-jasmine', 25 | 'karma-junit-reporter' 26 | ], 27 | 28 | junitReporter: { 29 | outputFile: 'test_out/unit.xml', 30 | suite: 'unit' 31 | } 32 | 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-seed", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "A starter project for AngularJS", 6 | "repository": "https://github.com/angular/angular-seed", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "bower": "^1.7.7", 10 | "http-server": "^0.10.0", 11 | "jasmine-core": "^2.4.1", 12 | "karma": "^0.13.22", 13 | "karma-chrome-launcher": "^0.2.3", 14 | "karma-firefox-launcher": "^0.1.7", 15 | "karma-jasmine": "^0.3.8", 16 | "karma-junit-reporter": "^0.4.1", 17 | "protractor": "^3.2.2" 18 | }, 19 | "scripts": { 20 | "postinstall": "cd app && bower install", 21 | "prestart": "npm install", 22 | "start": "http-server -a localhost -p 8000 -c-1 ./app", 23 | "pretest": "npm install", 24 | "test": "karma start karma.conf.js", 25 | "test-single-run": "karma start karma.conf.js --single-run", 26 | "preupdate-webdriver": "npm install", 27 | "update-webdriver": "webdriver-manager update", 28 | "preprotractor": "npm run update-webdriver", 29 | "protractor": "protractor e2e-tests/protractor.conf.js", 30 | "update-index-async": "node -e \"var fs=require('fs'),indexFile='app/index-async.html',loaderFile='app/bower_components/angular-loader/angular-loader.min.js',loaderText=fs.readFileSync(loaderFile,'utf-8').split(/sourceMappingURL=angular-loader.min.js.map/).join('sourceMappingURL=bower_components/angular-loader/angular-loader.min.js.map'),indexText=fs.readFileSync(indexFile,'utf-8').split(/\\/\\/@@NG_LOADER_START@@[\\s\\S]*\\/\\/@@NG_LOADER_END@@/).join('//@@NG_LOADER_START@@\\n'+loaderText+' //@@NG_LOADER_END@@');fs.writeFileSync(indexFile,indexText);\"" 31 | }, 32 | "dependencies": { 33 | "npm": "^6.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /views/haproxy.cfg: -------------------------------------------------------------------------------- 1 | # 2 | # DO NOT EDIT THIS FILE 3 | # Auto-generated by Sidecar at {{ now }} 4 | # 5 | 6 | global 7 | daemon 8 | {{ if .User }} user {{ .User }} {{ end }} 9 | {{ if .Group }} group {{ .Group }} {{ end }} 10 | maxconn 4096 11 | log 127.0.0.1 local0 12 | log 127.0.0.1 local1 notice 13 | stats socket /var/run/haproxy_stats.sock mode 666 level admin 14 | 15 | defaults 16 | log global 17 | option dontlognull 18 | maxconn 4096 19 | retries 3 20 | timeout connect 5s 21 | timeout client 1m 22 | timeout server 1m 23 | option redispatch 24 | balance roundrobin 25 | 26 | # -------------- STATS -------------- 27 | frontend stats_proxy 28 | mode http 29 | bind 0.0.0.0:3212 30 | http-response add-header Access-Control-Allow-Origin "*" 31 | default_backend stats_proxy 32 | 33 | backend stats_proxy 34 | mode http 35 | server localhost 0.0.0.0:32012 36 | 37 | frontend stats 38 | mode http 39 | bind 0.0.0.0:32012 40 | default_backend stats 41 | 42 | backend stats 43 | mode http 44 | http-response add-header Access-Control-Allow-Origin "*" 45 | stats enable 46 | stats uri / 47 | stats refresh 5s 48 | 49 | {{ range $svcName, $services := .Services }} {{ range $svcPort, $port := getPorts $svcName }} 50 | # ----------- {{ $svcName }} port {{ $svcPort }} -------------- 51 | frontend {{ sanitizeName $svcName }}-{{ $svcPort }} 52 | mode {{ getMode $svcName}} 53 | bind {{ bindIP }}:{{ $svcPort }} 54 | default_backend {{ sanitizeName $svcName }}-{{ $svcPort }} 55 | 56 | backend {{ sanitizeName $svcName }}-{{ $svcPort }} 57 | mode {{ getMode $svcName }} {{ range $svc := $services }} 58 | server {{ $svc.Hostname }}-{{ $svc.ID }} {{ ipFor $svcPort $svc }}:{{ portFor $svcPort $svc }} cookie {{ $svc.Hostname }}-{{ portFor $svcPort $svc }} {{ end }} 59 | {{ end }} 60 | {{ end }} 61 | -------------------------------------------------------------------------------- /views/static/Sidecar Architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/views/static/Sidecar Architecture.png -------------------------------------------------------------------------------- /views/static/Sidecar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/views/static/Sidecar.png -------------------------------------------------------------------------------- /views/static/sidecar-networking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/views/static/sidecar-networking.png -------------------------------------------------------------------------------- /views/static/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/views/static/youtube.png -------------------------------------------------------------------------------- /views/static/youtube2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinesStack/sidecar/6a309151b1de50830b0a985380ad77fc1ba70c76/views/static/youtube2.png --------------------------------------------------------------------------------